├── .eslintrc.js ├── .github └── workflows │ └── lint-and-test.yaml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── README.md ├── babel.config.js ├── content └── blog │ ├── post-01.md │ ├── post-02.md │ └── post-03.md ├── netlify.toml ├── next-mdx-relations ├── .gitignore ├── .npmignore ├── README.md ├── index.d.ts ├── index.js ├── package.json ├── rollup.config.ts ├── src │ ├── index.ts │ ├── types.ts │ └── utils.ts ├── tests │ ├── createUtils.test.ts │ ├── getPageProps.test.ts │ ├── getPages.test.ts │ ├── getPaths.test.ts │ ├── getPathsByProp.test.ts │ └── utilities.test.ts └── tsconfig.json ├── package.json ├── renovate.json ├── ts-example ├── .gitignore ├── README.md ├── next-env.d.ts ├── next-mdx-relations.config.ts ├── next.config.js ├── package.json ├── pages │ ├── [...slug].tsx │ ├── _app.tsx │ ├── about.tsx │ ├── blog.tsx │ └── index.tsx ├── public │ ├── favicon.ico │ └── vercel.svg ├── styles │ ├── Home.module.css │ └── globals.css ├── tsconfig.json └── types │ └── markdown-link-extractor │ └── index.d.ts ├── tsconfig.json └── vitest.config.ts /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es2021: true, 6 | jest: true, 7 | node: true 8 | }, 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/eslint-recommended', 12 | 'plugin:import/errors', 13 | 'plugin:import/warnings', 14 | 'plugin:import/typescript', 15 | 'prettier', 16 | 'plugin:prettier/recommended' 17 | ], 18 | parser: '@typescript-eslint/parser', 19 | parserOptions: { 20 | ecmaVersion: 12, 21 | sourceType: 'module' 22 | }, 23 | plugins: ['@typescript-eslint'], 24 | ignorePatterns: ['**/dist/*'], 25 | rules: { 26 | 'linebreak-style': ['error', 'unix'], 27 | 'no-unused-vars': 'off', 28 | '@typescript-eslint/no-unused-vars': ['error'], 29 | quotes: ['error', 'single'], 30 | semi: ['error', 'always'], 31 | 'no-undef': ['error'], 32 | 'prettier/prettier': 'error', 33 | 'no-console': 'off', 34 | 'import/extensions': [ 35 | 'error', 36 | 'ignorePackages', 37 | { 38 | js: 'never', 39 | jsx: 'never', 40 | ts: 'never', 41 | tsx: 'never' 42 | } 43 | ] 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /.github/workflows/lint-and-test.yaml: -------------------------------------------------------------------------------- 1 | name: Lint and Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | name: build, lint, and test on node 16 and ${{ matrix.os }} 8 | 9 | runs-on: ${{ matrix.os }} 10 | 11 | strategy: 12 | matrix: 13 | os: [ubuntu-latest] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: use node 16 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 16 21 | registry-url: https://registry.npmjs.org 22 | - name: install 23 | run: yarn 24 | - name: build 25 | run: yarn package:build 26 | - name: lint 27 | run: yarn lint 28 | - name: test 29 | run: yarn test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | node_modules 6 | /.pnp 7 | .pnp.js 8 | yarn.lock 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | /.next/ 15 | /out/ 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # Local Netlify folder 39 | .netlify 40 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 17.6.0 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "printWidth": 80, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "arrowParens": "avoid" 6 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-mdx-relations 2 | 3 | i have markdown. i want to generate relations between them. 4 | 5 | `next-mdx-relations` is a light set of utilities for generation relational data between markdown files. it is built to work with [`next-mdx-remote`](https://github.com/hashicorp/next-mdx-remote), which it uses as a peer dependency. `next-mdx-relations` abstracts away much of the boilerplate you would write to spin up an md/x powered next.js site, while providing control over how your md/x is processed. 6 | 7 | **Please Note**: This software is in early beta and the api is still prone to change. Please check readme between versions. 8 | 9 | ## toc 10 | 11 | 1. [getting started](#getting-started) 12 | 2. [example](#example) 13 | 3. [api](#api) 14 | 4. [typescript](#typescript) 15 | 5. [future](#future) 16 | 17 | ## getting started 18 | 19 | 1. Add package and peer dependencies 20 | 21 | ``` shell 22 | yarn add fast-glob gray-matter next-mdx-remote next-mdx-relations 23 | ``` 24 | 25 | 2. Create a `next-mdx-relations.config.js` in your project (preferably the root, but it can be anywhere) 26 | 27 | ``` js 28 | import { createUtils } from 'next-mdx-relations'; 29 | 30 | export const { 31 | getPaths, 32 | getPages, 33 | getPageProps, 34 | getPathsByProp 35 | } = createUtils({ 36 | content: '/content' // path to content 37 | }) 38 | ``` 39 | 40 | 3. Use any of the exported functions in `getStaticPaths` or `getStaticProps`. 41 | 42 | ## example 43 | 44 | I'm building a blog with a collection of posts written in markdown: 45 | 46 | ``` js 47 | // next-mdx-relations.config.js 48 | export const { 49 | getPaths, 50 | getPages, 51 | getPageProps, 52 | } = createUtils({ 53 | content: '/content' // path to content 54 | }) 55 | ``` 56 | 57 | ``` js 58 | // /pages/blog/index.js 59 | 60 | import React from 'react'; 61 | import Link from 'next/link'; 62 | import { getPages } from '../../next-mdx-relations.config.js'; 63 | 64 | // render a list of blog posts 65 | export default function BlogIndex({ posts }) { 66 | return ( 67 | {posts.map(({ 68 | frontmatter: { title, excerpt, id }, 69 | params: { slug } 70 | }) => ( 71 |
72 | 73 | {title} 74 | 75 |

{excerpt}

76 |
77 | ))} 78 | ) 79 | } 80 | 81 | export async function getStaticProps() { 82 | const posts = await getPages(); // gets an array of all pages in the /content directory 83 | 84 | return { 85 | props: { 86 | posts 87 | } 88 | }; 89 | } 90 | ``` 91 | 92 | ``` js 93 | // /pages/blog/[...slug].js 94 | // https://nextjs.org/docs/routing/dynamic-routes 95 | 96 | import React from 'react'; 97 | import { MDXRemote } from 'next-mdx-remote'; 98 | import { getPages } from '../../next-mdx-relations.config.js'; 99 | 100 | export default function Slug({ mdx, ...pageNode }) { 101 | const { frontmatter: { title, excerpt } } = pageNode; 102 | return ( 103 |
104 |

{title}

105 |

{excerpt}

106 | 107 |
108 | ) 109 | } 110 | 111 | export async function getStaticProps({ params: { slug } }) { 112 | const props = await getPageProps(slug); // returns pageProps and serialized mdx based on provided slug 113 | 114 | return { 115 | props 116 | }; 117 | } 118 | 119 | export async function getStaticPaths() { 120 | const paths = await getPaths(); // returns array of paths for generating pages 121 | 122 | return { 123 | paths, 124 | fallback: false 125 | }; 126 | } 127 | ``` 128 | 129 | ## api 130 | 131 | `next-mdx-relations` takes a config object and returns utilities for generating static content and relationships between them. Most of the api for actually generating the pages is what you would write yourself if you were spinning up a blog or statically generated next.js site. What `next-mdx-relations` provides is a suite of utilities that process your data, overrides to filesystem-based routing, and allows you to intervene at critical points in the content generation process. 132 | 133 | Behind the scenes, we do the following: 134 | 135 | 1. get all of the files from your content folder 136 | 2. for each file, we generate metadata based on config provided functions, and return everything you'd want to know about that file (frontmatter, filepath, params/slug, etc) 137 | 3. because we have access to all the files and metadata, we then allow for inserting custom relations between content. we can track when one file mentions another from the file being mentioned, for example 138 | 4. we sort the files based on provided sort criteria. we sort last in case you want to sort based on generated meta or relational data 139 | 140 | At the end of this process, you have all your files sorted, and you can filter down to what you need based on the meta and relational data that have been generated. 141 | 142 | ### config 143 | 144 | The config object can take the following parameters: 145 | 146 | #### content: string (required) 147 | 148 | This is the path to your content. It should be the root of your content folder -- you can handle different routes or processing different kinds of data differently by using `metaGenerators` and `relationGenerators`. 149 | 150 | The utilities `createUtils` returns will treat the content folder as a root for file system routing. The common use case would be collecting writing in a `/blog` folder and having the utilities return slugs for `/blog/post-title`. 151 | 152 | #### slugRewrites: object? 153 | 154 | `slugRewrites` is an object with key values pairs that correspond to a file's path (i.e. `content/blog`) and the rewritten slug path (`/garden`). This is one way to make routing among different collections of files uniform. 155 | 156 | #### sort: object? 157 | 158 | ``` js 159 | sort: { 160 | by: 'frontmatter.date', // string path to value 161 | order: 'desc' // asc | desc 162 | } 163 | 164 | ``` 165 | 166 | `sort` takes an object with two keys. `by` is a stringified path to a particular item associated with the pages (i.e. date or type). `order` takes a string of either 'asc' or 'desc' and corresponds to the sort order. 167 | 168 | #### metaGenerators: object? 169 | 170 | `metaGenerators` is an object consisting of key value pairs that correspond to a metadata attribute and a function used to generate that attribute. An example of this would be taking an isoDate and converting it to a string. 171 | 172 | ``` js 173 | import { DateTime } from 'luxon'; 174 | import { createUtils } from 'next-mdx-relations'; 175 | 176 | export const { 177 | getPaths, 178 | getPages 179 | } = createUtils({ 180 | content: '/content', 181 | metaGenerators: { 182 | date: node => { 183 | const { frontmatter: { date } } = node; 184 | if (date) { 185 | const isoDate = DateTime.fromISO(date); 186 | return isoDate.toLocaleString(DateTime.DATE_FULL); 187 | } 188 | return null; 189 | } 190 | } 191 | }) 192 | ``` 193 | 194 | `metaGenerators` have access to the `node` or `file`. Anything in the content or frontmatter of the file is available to add additional meta. Note that these parameters are not combined with the frontmatter in the file but placed in their own `meta` object so as not to override anything static in the file itself. 195 | 196 | #### relationGenerators: object? 197 | 198 | `relationGenerators` is an object consisting of key value pairs that correspond to a relational attribute and the function used to generate that attribute. These functions have access to all `nodes` after they've been read and `metaGenerators` have been run. We'll use the provided key to add the returned data to each page's `meta` object. 199 | 200 | The function values provided to `relationalGenerators` are passed directly into a `.map` function, so you have access to each item within the map as well as the array itself. 201 | 202 | ```js 203 | // return the index within the array of nodes 204 | index: (_, index) => index 205 | ``` 206 | 207 | If you need to transform data before generating some relational data (like if you need to sort or filter the content), you can pass an object with `transform` and `map` as the keys. We'll use the `transform` function to keep track of items from the array of nodes, and the map function to actually return an array of relations. 208 | 209 | ```js 210 | // return two pieces of metadata, `prev` and `next` 211 | '[prev, next]': { 212 | // we have not sorted all our files yet, so to create 213 | // directional links, we'd have to do it here 214 | transform: nodes => nodes.sort((a, b) => a?.meta?.date - b?.meta?.date), 215 | map: (node, index, array) => { 216 | const prev = index > 0 ? array[index - 1] : null; 217 | const next = index < array.length -1 ? array[index + 1] : null; 218 | return { prev, next }; 219 | } 220 | ``` 221 | 222 | Note that `map` is also just a function being passed into a `.map` after `transform` takes place. We use `filePath` to reconcile nodes before/after transformation takes place, so mutating the `filePath` will cause issues for this function. 223 | 224 | `relation` keys can be defined either as a string or as a stringified array. In the example above, we're generating both the previous and next page/post. Rather than break these out into two different generators, we can generate both values in one function, and each value will be name spaced correctly. 225 | 226 | Like `metaGenerators`, `relationGenerators` has access to the whole node, but only (re)places data set in `node.meta`. This prevents unintended mutations of static data. 227 | 228 | #### mdxOptions: MDXOptions? 229 | 230 | Because we're interfacing with `next-mdx-remote`, this object allows us to pass in `MDXOptions`. You can see [their documentation](https://github.com/hashicorp/next-mdx-remote#apis) for more details. 231 | 232 | ### functions 233 | 234 | The `createUtils` function generates the following functions based on the provided config object: 235 | 236 | #### `await getPaths`: string? 237 | 238 | `getPaths` returns an array of paths for generating pages when used in a catchall `[...slug].js`'s `getStaticPaths`. It takes an optional string parameter which overrides the content directory specified in the config object. For example, if you have nested folders and you want paths just for a subset of folders, you could pass that directory in here. 239 | 240 | ``` js 241 | const paths = await getPaths(); // all paths from content folder 242 | const subSetOfPaths = await getPaths('/content/blog'); // paths from /content/blog folder 243 | ``` 244 | 245 | #### `await getPages`: { meta: object?, frontmatter:object? }: object? 246 | 247 | `getPages` returns an array of pages, including frontmatter, metadata, and relational data (everything but the serialized markdown content) based on the files in the content directory specified in the config object. It optionally takes an object that includes keys for `meta` and `frontmatter`, allowing you to filter for a specific subset of pages. 248 | 249 | ``` js 250 | const drafts = await getPages({ frontmatter: { draft: true } }); // pages set to draft: true 251 | const published = await getPages({ frontmatter: { draft: false } }); // pages set to draft: false 252 | const gardenPosts = await getPages({ frontmatter: { type: 'garden' } }); // pages with type 'garden' 253 | const postsTaggedReactorNextJS = await getPages({ frontmatter: { draft: false, tags: ['react', 'nextjs'] } }); // pages with draft false and tags that include 'react' and/or 'nextjs' 254 | ``` 255 | 256 | #### `getPageProps`: string | string[] 257 | 258 | `getPageProps` returns a page, including frontmatter, metadata, relational data, and serialized markdown content based on a provided slug. It is used in conjunction with `getPaths` in a catchall `[...slug].js` file. See the `[...slug].js` in the above [example](#example). 259 | 260 | Below, you'll find the object `getPageProps` returns. Note that the `mdx` value should be passed into `next-mdx-remote`'s `MDXRemote` remote component. 261 | 262 | ``` ts 263 | const { 264 | params: { 265 | slug: string[] 266 | }, 267 | filePath: string, 268 | content: string, 269 | frontmatter: any, 270 | meta: any, 271 | mdx: MDXRemoteSerializedResult 272 | } = await getPageProps(); 273 | ``` 274 | 275 | #### `getPathsByProp`: string 276 | 277 | `getPathsByProp` takes a key value in dot notation that corresponds to a page's frontmatter or a piece of meta- or relational data and returns an array of paths that correspond to that prop. You might use something like this if you wanted a list of tags and generate pages for those tags. Below is an example of the `getStaticPaths` and `getStaticProps` to generate tag pages. 278 | 279 | ``` js 280 | // pages/tag/[tag].js 281 | 282 | ... 283 | 284 | export async function getStaticProps({ params: { tag } }) { 285 | const posts = await getPages({ 286 | frontmatter: { draft: null, tags: tag } 287 | }); 288 | 289 | return { 290 | props: { 291 | tag, 292 | posts 293 | } 294 | }; 295 | }; 296 | 297 | export async function getStaticPaths() { 298 | const paths = await getPathsByProp('frontmatter.tags'); 299 | const test = paths.map(p => ({ params: { tag: p } })); 300 | 301 | return { 302 | paths: test, 303 | fallback: false 304 | }; 305 | } 306 | ``` 307 | 308 | ## typescript 309 | 310 | `next-mdx-relations` was written in and supports typescript. See the `ts-example` repo for an overview. Types can be exported from `next-mdx-relations`. 311 | 312 | ``` ts 313 | import { File, MetaGenerator, Page, MDXPage, Params, RelationalGenerator, RelationsConfig, Sort } from 'next-mdx-relations'; 314 | ``` 315 | 316 | See [`types.ts`](https://github.com/inadeqtfuturs/next-mdx-relations/blob/main/next-mdx-relations/src/types.ts) for an overview. 317 | 318 | ## future 319 | 320 | `next-mdx-relations` is in early days. Some things I'd like to do moving forward: 321 | 322 | - [ ] more granular `getPathsByProp` api 323 | - [ ] more granular `getPages` api and control over filtering 324 | 325 | Have other ideas? Feel free to file an issue or submit a PR. 326 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript' 5 | ] 6 | }; 7 | -------------------------------------------------------------------------------- /content/blog/post-01.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Post 1 3 | author: if 4 | tags: 5 | - react 6 | - nextjs 7 | featured: true 8 | secret: '42' 9 | --- 10 | 11 | This is a test post about react and nextjs. -------------------------------------------------------------------------------- /content/blog/post-02.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Post 2 3 | author: if 4 | tags: 5 | - nextjs 6 | - styled components 7 | --- 8 | 9 | This is a test post about nextjs and styled components. -------------------------------------------------------------------------------- /content/blog/post-03.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Post 3 3 | author: if 4 | tags: 5 | - react 6 | - styled components 7 | - random 8 | --- 9 | 10 | This is a test post about react and styled components. 11 | 12 | It also references [another post](/blog/post-02). 13 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[plugins]] 2 | package = "@netlify/plugin-nextjs" 3 | 4 | [build] 5 | command = "yarn package:build && yarn example:build" 6 | publish = "ts-example/.next" 7 | -------------------------------------------------------------------------------- /next-mdx-relations/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | node_modules 6 | /.pnp 7 | .pnp.js 8 | yarn.lock 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | /.next/ 15 | /out/ 16 | 17 | # production 18 | /dist 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | # vercel 36 | .vercel 37 | -------------------------------------------------------------------------------- /next-mdx-relations/.npmignore: -------------------------------------------------------------------------------- 1 | tsconfig.json 2 | src 3 | tests 4 | -------------------------------------------------------------------------------- /next-mdx-relations/README.md: -------------------------------------------------------------------------------- 1 | # next-mdx-relations 2 | 3 | i have markdown. i want to generate relations between them. 4 | 5 | `next-mdx-relations` is a light set of utilities for generation relational data between markdown files. it is built to work with [`next-mdx-remote`](https://github.com/hashicorp/next-mdx-remote), which it uses as a peer dependency. `next-mdx-relations` abstracts away much of the boilerplate you would write to spin up an md/x powered next.js site, while providing control over how your md/x is processed. 6 | 7 | ## toc 8 | 9 | 1. [getting started](#getting-started) 10 | 2. [example](#example) 11 | 3. [api](#api) 12 | 4. [typescript](#typescript) 13 | 5. [future](#future) 14 | 15 | ## getting started 16 | 17 | 1. Add package and peer dependencies 18 | 19 | ``` shell 20 | yarn add fast-glob gray-matter next-mdx-remote next-mdx-relations 21 | ``` 22 | 23 | 2. Create a `next-mdx-relations.config.js` in your project (preferably the root, but it can be anywhere) 24 | 25 | ``` js 26 | import { createUtils } from 'next-mdx-relations'; 27 | 28 | export const { 29 | getPaths, 30 | getPages, 31 | getPageProps, 32 | getPathsByProp 33 | } = createUtils({ 34 | content: '/content' // path to content 35 | }) 36 | ``` 37 | 38 | 3. Use any of the exported functions in `getStaticPaths` or `getStaticProps`. 39 | 40 | ## example 41 | 42 | I'm building a blog with a collection of posts written in markdown: 43 | 44 | ``` js 45 | // next-mdx-relations.config.js 46 | export const { 47 | getPaths, 48 | getPages, 49 | getPageProps, 50 | } = createUtils({ 51 | content: '/content' // path to content 52 | }) 53 | ``` 54 | 55 | ``` js 56 | // /pages/blog/index.js 57 | 58 | import React from 'react'; 59 | import Link from 'next/link'; 60 | import { getPages } from '../../next-mdx-relations.config.js'; 61 | 62 | // render a list of blog posts 63 | export default function BlogIndex({ posts }) { 64 | return ( 65 | {posts.map(({ 66 | frontmatter: { title, excerpt, id }, 67 | params: { slug } 68 | }) => ( 69 |
70 | 71 | {title} 72 | 73 |

{excerpt}

74 |
75 | ))} 76 | ) 77 | } 78 | 79 | export async function getStaticProps() { 80 | const posts = await getPages(); // gets an array of all pages in the /content directory 81 | 82 | return { 83 | props: { 84 | posts 85 | } 86 | }; 87 | } 88 | ``` 89 | 90 | ``` js 91 | // /pages/blog/[...slug].js 92 | // https://nextjs.org/docs/routing/dynamic-routes 93 | 94 | import React from 'react'; 95 | import { MDXRemote } from 'next-mdx-remote'; 96 | import { getPages } from '../../next-mdx-relations.config.js'; 97 | 98 | export default function Slug({ mdx, ...pageNode }) { 99 | const { frontmatter: { title, excerpt } } = pageNode; 100 | return ( 101 |
102 |

{title}

103 |

{excerpt}

104 | 105 |
106 | ) 107 | } 108 | 109 | export async function getStaticProps({ params: { slug } }) { 110 | const props = await getPageProps(slug); // returns pageProps and serialized mdx based on provided slug 111 | 112 | return { 113 | props 114 | }; 115 | } 116 | 117 | export async function getStaticPaths() { 118 | const paths = await getPaths(); // returns array of paths for generating pages 119 | 120 | return { 121 | paths, 122 | fallback: false 123 | }; 124 | } 125 | ``` 126 | 127 | ## api 128 | 129 | `next-mdx-relations` takes a config object and returns utilities for generating static content and relationships between them. Most of the api for actually generating the pages is what you would write yourself if you were spinning up a blog or statically generated next.js site. What `next-mdx-relations` provides is a suite of utilities that process your data, overrides to filesystem-based routing, and allows you to intervene at critical points in the content generation process. 130 | 131 | Behind the scenes, we do the following: 132 | 133 | 1. get all of the files from your content folder 134 | 2. for each file, we generate metadata based on config provided functions, and return everything you'd want to know about that file (frontmatter, filepath, params/slug, etc) 135 | 3. because we have access to all the files and metadata, we then allow for inserting custom relations between content. we can track when one file mentions another from the file being mentioned, for example 136 | 4. we sort the files based on provided sort criteria 137 | 138 | At the end of this process, you have all your files sorted, and you can filter down to what you need based on the metadata and relations that have been generated. 139 | 140 | ### config 141 | 142 | The config object can take the following parameters: 143 | 144 | #### content: string (required) 145 | 146 | This is the path to your content. It should be the root of your content folder -- you can handle different routes or processing different kinds of data differently by using `metaGenerators` and `relationGenerators`. 147 | 148 | The utilities `createUtils` returns will treat the content folder as a root for file system routing. The common use case would be collecting writing in a `/blog` folder and having the utilities return slugs for `/blog/post-title`. 149 | 150 | #### slugRewrites: object? 151 | 152 | `slugRewrites` is an object with key values pairs that correspond to a file's path (i.e. `content/blog`) and the rewritten slug path (`/garden`). This is one way to make routing among different collections of files uniform. 153 | 154 | #### sort: object? 155 | 156 | ``` js 157 | sort: { 158 | by: 'frontmatter.date', // string path to value 159 | order: 'desc' // asc | desc 160 | } 161 | 162 | ``` 163 | 164 | `sort` takes an object with two keys. `by` is a stringified path to a particular item associated with the pages (i.e. date or type). `order` takes a string of either 'asc' or 'desc' and corresponds to the sort order. 165 | 166 | #### metaGenerators: object? 167 | 168 | `metaGenerators` is an object consisting of key value pairs that correspond to a metadata attribute and a function used to generate that attribute. An example of this would be taking an isoDate and converting it to a string. 169 | 170 | ``` js 171 | import { DateTime } from 'luxon'; 172 | import { createUtils } from 'next-mdx-relations'; 173 | 174 | export const { 175 | getPaths, 176 | getPages 177 | } = createUtils({ 178 | content: '/content', 179 | metaGenerators: { 180 | date: node => { 181 | const { frontmatter: { date } } = node; 182 | if (date) { 183 | const isoDate = DateTime.fromISO(date); 184 | return isoDate.toLocaleString(DateTime.DATE_FULL); 185 | } 186 | return null; 187 | } 188 | } 189 | }) 190 | ``` 191 | 192 | `metaGenerators` have access to the `node` or `file`. Anything in the content or frontmatter of the file is available to add additional meta. Note that these parameters are not combined with the frontmatter in the file but placed in their own `meta` object so as not to override anything static in the file itself. 193 | 194 | #### relationGenerators: object? 195 | 196 | `relationGenerators` is an object consisting of key value pairs that correspond to a relational attribute and the function used to generate that attribute. These functions have access to all `nodes` after they've been read and `metaGenerators` have been run. 197 | 198 | ``` js 199 | import { DateTime } from 'luxon'; 200 | import { createUtils } from 'next-mdx-relations'; 201 | 202 | export const { 203 | getPaths, 204 | getPages 205 | } = createUtils({ 206 | content: '/content', 207 | relationGenerators: { 208 | directionalLinks: nodes => { 209 | const sortedNodes = nodes 210 | // we have not sorted all our files yet, so to create 211 | // directional links, we'd have to do it here 212 | .sort((a, b) => a?.meta?.date - b?.meta?.date) 213 | .map((node, index, array) => { 214 | const prev = index > 0 ? array[index - 1] : null; 215 | const next = index < array.length -1 ? array[index + 1] : null; 216 | return { 217 | ...node, 218 | meta: { 219 | ...node.meta, 220 | prev, 221 | next 222 | } 223 | }; 224 | }); 225 | return sortedNodes; 226 | }, 227 | } 228 | }) 229 | ``` 230 | 231 | In its current form, you can mutate any part of a given node using `relationGenerators`. In general, it's best to add this relational data to the `meta` attribute as to not mutate the frontmatter or other parts of the node. 232 | 233 | #### mdxOptions: MDXOptions? 234 | 235 | Because we're interfacing with `next-mdx-remote`, this object allows us to pass in `MDXOptions`. You can see [their documentation](https://github.com/hashicorp/next-mdx-remote#apis) for more details. 236 | 237 | ### functions 238 | 239 | The `createUtils` function generates the following functions based on the provided config object: 240 | 241 | #### `await getPaths`: string? 242 | 243 | `getPaths` returns an array of paths for generating pages when used in a catchall `[...slug].js`'s `getStaticPaths`. It takes an optional string parameter which overrides the content directory specified in the config object. For example, if you have nested folders and you want paths just for a subset of folders, you could pass that directory in here. 244 | 245 | ``` js 246 | const paths = await getPaths(); // all paths from content folder 247 | const subSetOfPaths = await getPaths('/content/blog'); // paths from /content/blog folder 248 | ``` 249 | 250 | #### `await getPages`: { meta: object?, frontmatter:object? }: object? 251 | 252 | `getPages` returns an array of pages, including frontmatter, metadata, and relational data (everything but the serialized markdown content) based on the files in the content directory specified in the config object. It optionally takes an object that includes keys for `meta` and `frontmatter`, allowing you to filter for a specific subset of pages. 253 | 254 | ``` js 255 | const drafts = await getPages({ frontmatter: { draft: true } }); // pages set to draft: true 256 | const published = await getPages({ frontmatter: { draft: false } }); // pages set to draft: false 257 | const gardenPosts = await getPages({ frontmatter: { type: 'garden' } }); // pages with type 'garden' 258 | const postsTaggedReactorNextJS = await getPages({ frontmatter: { draft: false, tags: ['react', 'nextjs'] } }); // pages with draft false and tags that include 'react' and/or 'nextjs' 259 | ``` 260 | 261 | #### `getPageProps`: string | string[] 262 | 263 | `getPageProps` returns a page, including frontmatter, metadata, relational data, and serialized markdown content based on a provided slug. It is used in conjunction with `getPaths` in a catchall `[...slug].js` file. See the `[...slug].js` in the above [example](#example). 264 | 265 | Below, you'll find the object `getPageProps` returns. Note that the `mdx` value should be passed into `next-mdx-remote`'s `MDXRemote` remote component. 266 | 267 | ``` ts 268 | const { 269 | params: { 270 | slug: string[] 271 | }, 272 | filePath: string, 273 | content: string, 274 | frontmatter: any, 275 | meta: any, 276 | mdx: MDXRemoteSerializedResult 277 | } = await getPageProps(); 278 | ``` 279 | 280 | #### `getPathsByProp`: string 281 | 282 | `getPathsByProp` takes a key value in dot notation that corresponds to a page's frontmatter or a piece of meta- or relational data and returns an array of paths that correspond to that prop. You might use something like this if you wanted a list of tags and generate pages for those tags. Below is an example of the `getStaticPaths` and `getStaticProps` to generate tag pages. 283 | 284 | ``` js 285 | // pages/tag/[tag].js 286 | 287 | ... 288 | 289 | export async function getStaticProps({ params: { tag } }) { 290 | const posts = await getPages({ 291 | frontmatter: { draft: null, tags: tag } 292 | }); 293 | 294 | return { 295 | props: { 296 | tag, 297 | posts 298 | } 299 | }; 300 | }; 301 | 302 | export async function getStaticPaths() { 303 | const paths = await getPathsByProp('frontmatter.tags'); 304 | const test = paths.map(p => ({ params: { tag: p } })); 305 | 306 | return { 307 | paths: test, 308 | fallback: false 309 | }; 310 | } 311 | ``` 312 | 313 | ## typescript 314 | 315 | `next-mdx-relations` was written in and supports typescript. See the `ts-example` repo for an overview. Types can be exported from `next-mdx-relations`. 316 | 317 | ``` ts 318 | import { File, MetaGenerator, Page, MDXPage, Params, RelationalGenerator, RelationsConfig, Sort } from 'next-mdx-relations'; 319 | ``` 320 | 321 | See [`types.ts`](https://github.com/inadeqtfuturs/next-mdx-relations/blob/main/next-mdx-relations/src/types.ts) for an overview. 322 | 323 | ## future 324 | 325 | `next-mdx-relations` is in early days. Some things I'd like to do moving forward: 326 | 327 | [ ]: more granular `getPathsByProp` api 328 | [ ]: handle slugs and dashed tags 329 | [ ]: more granular `getPages` api and control over filtering 330 | [ ]: explicit `frontmatter` and `meta` types 331 | [ ]: implement more guard rails for manipulating data (prevent overwriting `frontmatter` or `meta`) 332 | -------------------------------------------------------------------------------- /next-mdx-relations/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from './dist/index'; 2 | -------------------------------------------------------------------------------- /next-mdx-relations/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | createUtils, 3 | getPageProps, 4 | getPages, 5 | getPaths, 6 | getPathsByProp 7 | } from './dist/index'; 8 | 9 | export { createUtils, getPageProps, getPages, getPaths, getPathsByProp }; 10 | -------------------------------------------------------------------------------- /next-mdx-relations/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-mdx-relations", 3 | "version": "0.0.16", 4 | "description": "utilities for generating relationship between md/x files", 5 | "author": "if", 6 | "license": "MIT", 7 | "type": "module", 8 | "types": "index.d.ts", 9 | "exports": { 10 | ".": "./dist/index.js" 11 | }, 12 | "scripts": { 13 | "build": "rm -rf dist && rollup --config", 14 | "release": "yarn build && yarn publish" 15 | }, 16 | "peerDependencies": { 17 | "fast-glob": "^3.2.5", 18 | "gray-matter": "^4.0.3", 19 | "next-mdx-remote": "^4.0.3" 20 | }, 21 | "devDependencies": { 22 | "@rollup/plugin-commonjs": "22.0.0", 23 | "@rollup/plugin-typescript": "8.3.3", 24 | "@types/node": "17.0.33", 25 | "rollup": "2.75.6", 26 | "rollup-plugin-analyzer": "4.0.0", 27 | "rollup-plugin-peer-deps-external": "2.2.4", 28 | "rollup-plugin-terser": "7.0.2", 29 | "typescript": "4.7.4" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /next-mdx-relations/rollup.config.ts: -------------------------------------------------------------------------------- 1 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 2 | import ts from '@rollup/plugin-typescript'; 3 | import { terser } from 'rollup-plugin-terser'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import analyze from 'rollup-plugin-analyzer'; 6 | 7 | export default [ 8 | { 9 | input: 'src/index.ts', 10 | output: { 11 | dir: 'dist', 12 | format: 'es' 13 | }, 14 | external: ['path', 'fs'], 15 | plugins: [ 16 | peerDepsExternal(), 17 | ts({ 18 | tsconfig: './tsconfig.json', 19 | declaration: true, 20 | declarationDir: './dist' 21 | }), 22 | terser({ 23 | ecma: 2018, 24 | mangle: { toplevel: true }, 25 | compress: { 26 | module: true, 27 | toplevel: true, 28 | unsafe_arrows: true 29 | }, 30 | output: { quote_style: 1 } 31 | }), 32 | commonjs(), 33 | analyze() 34 | ] 35 | } 36 | ]; 37 | -------------------------------------------------------------------------------- /next-mdx-relations/src/index.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import matter from 'gray-matter'; 3 | import { serialize } from 'next-mdx-remote/serialize'; 4 | 5 | import { 6 | getFiles, 7 | getPathValues, 8 | getSimplifiedSlug, 9 | getValueFromPath, 10 | isEmpty 11 | } from './utils'; 12 | 13 | import { 14 | File, 15 | MetaGenerator, 16 | Page, 17 | MDXPage, 18 | Params, 19 | RelationalGenerator, 20 | RelationsConfig, 21 | Sort 22 | } from './types'; 23 | 24 | export async function getPaths( 25 | config: RelationsConfig, 26 | pathToContent?: string 27 | ): Promise { 28 | const usePath = pathToContent || config.content; 29 | const files = await getFiles(config, usePath); 30 | 31 | const paths = files.map(({ params }) => ({ params })); 32 | return paths; 33 | } 34 | 35 | async function generatePage(file: File): Promise { 36 | const mdxSource = await fs.readFile(file.filePath); 37 | const { content, data: frontmatter } = matter(mdxSource); 38 | 39 | return { 40 | ...file, 41 | content, 42 | frontmatter 43 | }; 44 | } 45 | 46 | async function generateMeta( 47 | page: Page, 48 | metaGenerators: Record | null = null 49 | ): Promise<{} | undefined> { 50 | if (!metaGenerators) return; 51 | return Object.entries(metaGenerators).reduce( 52 | (acc, [k, v]) => ({ 53 | ...acc, 54 | [k]: v(page) 55 | }), 56 | {} 57 | ); 58 | } 59 | 60 | const keymapRegex = new RegExp(/\[([^\]]+)]/, 'i'); 61 | 62 | async function generateRelations( 63 | pages: Page[], 64 | relationGenerators: Record | null = null 65 | ): Promise { 66 | if (!relationGenerators) return pages; 67 | 68 | const relations = Object.entries(relationGenerators).reduce( 69 | (acc, [key, generator]) => { 70 | const match = key.match(keymapRegex); 71 | const transform = typeof generator !== 'function' && generator.transform; 72 | const map = typeof generator !== 'function' ? generator.map : generator; 73 | 74 | const transformData: Page[] = transform ? transform(pages) : pages; 75 | const results: Record = transformData 76 | .map(map) 77 | .reduce( 78 | (acc, x, i) => ({ ...acc, [transformData[i].filePath]: x }), 79 | {} 80 | ); 81 | 82 | if (match) { 83 | const keymap = match[1].replace(' ', '').split(','); 84 | return acc.map(({ filePath, ...rest }) => { 85 | const mappedResults = keymap.reduce( 86 | (keyAcc: Record, k: string): any => { 87 | if (results[filePath][k]) { 88 | return { ...keyAcc, [k]: results[filePath][k] }; 89 | } 90 | return keyAcc; 91 | }, 92 | {} 93 | ); 94 | 95 | return { 96 | filePath, 97 | ...rest, 98 | meta: { ...rest.meta, ...mappedResults } 99 | }; 100 | }); 101 | } 102 | 103 | return acc.map(({ filePath, ...rest }) => ({ 104 | filePath, 105 | ...rest, 106 | meta: { ...rest.meta, [key]: results[filePath] } 107 | })); 108 | }, 109 | pages 110 | ); 111 | 112 | return relations; 113 | } 114 | 115 | function sortPages(pages: Page[], sort: Sort | undefined): Page[] { 116 | if (!sort) return pages; 117 | const { by, order } = sort; 118 | 119 | const sortedPages = pages.sort((a, b) => { 120 | const bValue = getValueFromPath(b, by); 121 | const aValue = getValueFromPath(a, by); 122 | return bValue - aValue; 123 | }); 124 | if (order === 'asc') { 125 | return sortedPages.reverse(); 126 | } 127 | return sortedPages; 128 | } 129 | 130 | function filterPages( 131 | pages: Page[], 132 | { meta, frontmatter }: Partial 133 | ): Page[] { 134 | const pathValues = getPathValues({ meta, frontmatter }); 135 | if (!Array.isArray(pathValues)) return pages; 136 | 137 | const filteredPages = pages.filter(page => 138 | pathValues.reduce((bool: Boolean, { objectPath, value }) => { 139 | const pageValue = getValueFromPath(page, objectPath); 140 | // if pageValue contains ALL value 141 | if (Array.isArray(pageValue) && Array.isArray(value)) { 142 | return value.every(v => pageValue.includes(v)) && bool; 143 | } 144 | // if pageValue contains value 145 | if (Array.isArray(pageValue) && typeof value === 'string') { 146 | return pageValue.includes(value) && bool; 147 | } 148 | // if value contains pageValue 149 | if (typeof pageValue === 'string' && Array.isArray(value)) { 150 | return value.includes(pageValue) && bool; 151 | } 152 | // bool 153 | if (typeof value === 'boolean') { 154 | return value === pageValue && bool; 155 | } 156 | // if pageValue === value 157 | return pageValue === value && bool; 158 | }, true) 159 | ); 160 | 161 | return filteredPages; 162 | } 163 | 164 | export async function getPages( 165 | config: RelationsConfig, 166 | { meta = {}, frontmatter = {} }: Partial = {} 167 | ): Promise { 168 | const files = await getFiles(config); 169 | if (!files.length) return []; 170 | 171 | const { metaGenerators, relationGenerators, sort } = config; 172 | 173 | const pages = await Promise.all( 174 | files.map(async file => { 175 | const page = await generatePage(file); 176 | const pageMeta = await generateMeta(page, metaGenerators); 177 | return pageMeta ? { ...page, meta: pageMeta } : page; 178 | }) 179 | ).then(async response => { 180 | const relatedPages = await generateRelations(response, relationGenerators); 181 | const sortedPages = sortPages(relatedPages, sort); 182 | 183 | if (!isEmpty(meta) || !isEmpty(frontmatter)) { 184 | const filteredPages = filterPages(sortedPages, { meta, frontmatter }); 185 | return filteredPages; 186 | } 187 | return sortedPages; 188 | }); 189 | 190 | return pages; 191 | } 192 | 193 | export async function getPageProps( 194 | config: RelationsConfig, 195 | slug: string | string[] 196 | ): Promise { 197 | const pages = await getPages(config); 198 | const page = pages.find( 199 | p => JSON.stringify(p.params.slug) === JSON.stringify(slug) 200 | ); 201 | if (!page) return null; 202 | const { frontmatter, content } = page; 203 | const { mdxOptions } = config; 204 | const mdx = await serialize(content, { 205 | scope: frontmatter, 206 | mdxOptions: { ...mdxOptions } 207 | }); 208 | 209 | return { 210 | ...page, 211 | mdx 212 | }; 213 | } 214 | 215 | // return a set of paths for a given tag/meta item 216 | // todo: clean up 217 | // add argument for setting params 218 | export async function getPathsByProp( 219 | config: RelationsConfig, 220 | prop: string 221 | ): Promise { 222 | const pages = await getPages(config, {}); 223 | const paths = pages.reduce((acc: string[], curr: Page) => { 224 | const pageProp = getValueFromPath(curr, prop); 225 | if (!pageProp) return acc; 226 | if (Array.isArray(pageProp)) { 227 | const propSlugs = pageProp.map(p => getSimplifiedSlug(p)); 228 | return [...acc, ...propSlugs]; 229 | } 230 | const propSlug = getSimplifiedSlug(pageProp); 231 | return [...acc, propSlug]; 232 | }, []); 233 | 234 | return [...new Set(paths)]; 235 | } 236 | 237 | export function createUtils(config: RelationsConfig) { 238 | return { 239 | getPaths: (pathToContent?: string) => getPaths(config, pathToContent), 240 | getPathsByProp: (prop: string) => getPathsByProp(config, prop), 241 | getPages: ({ meta, frontmatter }: Record = {}) => 242 | getPages(config, { meta, frontmatter }), 243 | getPageProps: (slug: string | string[]) => getPageProps(config, slug) 244 | }; 245 | } 246 | 247 | export { 248 | File, 249 | MetaGenerator, 250 | Page, 251 | MDXPage, 252 | Params, 253 | RelationalGenerator, 254 | RelationsConfig, 255 | Sort 256 | }; 257 | -------------------------------------------------------------------------------- /next-mdx-relations/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Pluggable, Compiler } from 'unified'; 2 | import { MDXRemoteSerializeResult } from 'next-mdx-remote'; 3 | 4 | export type PathValue = { 5 | objectPath: string[]; 6 | value: string[]; 7 | }; 8 | 9 | export type Params = { 10 | params: { 11 | slug: string[]; 12 | }; 13 | }; 14 | 15 | export type File = Params & { filePath: string }; 16 | 17 | export type Metadata = { 18 | [key: string]: any; 19 | }; 20 | 21 | export type Page = File & { 22 | content: string; 23 | frontmatter?: any; 24 | meta?: any; 25 | }; 26 | 27 | export type MDXPage = Page & { mdx: MDXRemoteSerializeResult }; 28 | 29 | export type Sort = { 30 | by: string; 31 | order: 'asc' | 'desc'; 32 | }; 33 | 34 | export type MetaGenerator = (page: Page) => typeof page; 35 | export type RelationalGenerator = 36 | | { 37 | transform: (pages: Page[]) => Page[]; 38 | map: (item: T, index: number, array: T[]) => any; 39 | } 40 | | ((item: Page, index: number, array: Page[]) => any); 41 | 42 | export type MDXOptions = { 43 | remarkPlugins?: Pluggable[]; 44 | rehypePlugins?: Pluggable[]; 45 | hastPlugins?: Pluggable[]; 46 | compilers?: Compiler[]; 47 | filepath?: string; 48 | }; 49 | 50 | export type RelationsConfig = { 51 | content: string; 52 | slugRewrites?: Record; 53 | sort?: Sort; 54 | metaGenerators?: Record; 55 | relationGenerators?: Record; 56 | mdxOptions?: MDXOptions; 57 | }; 58 | -------------------------------------------------------------------------------- /next-mdx-relations/src/utils.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import glob from 'fast-glob'; 3 | 4 | import { File, PathValue, RelationsConfig } from './types'; 5 | 6 | // a basic isEmpty function 7 | export function isEmpty(o: Object) { 8 | return Object.keys(o).length === 0 && o.constructor === Object; 9 | } 10 | 11 | export function getValueFromPath( 12 | o: Record, 13 | p: string[] | string 14 | ): any { 15 | let usePath = p; 16 | if (typeof p === 'string' || p instanceof String) { 17 | usePath = p.split('.'); 18 | } 19 | 20 | if (!Array.isArray(usePath)) { 21 | throw new Error('Please provide a path to `getValueFromPath`'); 22 | } 23 | 24 | return usePath.reduce( 25 | , K extends keyof T>(xs: T, x: K) => 26 | xs && xs[x] ? xs[x] : null, 27 | o 28 | ); 29 | } 30 | 31 | /** 32 | * @description recursive function takes an object and returns object with path and value 33 | * @returns PathValue[] 34 | */ 35 | export function getPathValues( 36 | o: string[] | Object = {}, 37 | p: string[] = [] 38 | ): Object | PathValue[] { 39 | return Array.isArray(o) || Object(o) !== o 40 | ? { objectPath: p, value: o } 41 | : Object.entries(o).flatMap(([k, v]) => getPathValues(v, [...p, k])); 42 | } 43 | 44 | export function getSimplifiedSlug(s: string): string { 45 | return s 46 | .normalize('NFKD') 47 | .toLowerCase() 48 | .replace(/[^\w\s-]/g, '') 49 | .trim() 50 | .replace(/[-\s]+/g, '-'); 51 | } 52 | 53 | export async function getFiles( 54 | config: RelationsConfig, 55 | pathToFiles?: string 56 | ): Promise { 57 | const usePath = pathToFiles || config.content; 58 | const slugRewrites = config?.slugRewrites || null; 59 | const pathToContent = path.join(process.cwd(), usePath); 60 | const files = await glob.sync(`${pathToContent}/**/*.(md|mdx)`, { 61 | ignore: ['**/node_modules/**'] 62 | }); 63 | if (!files) return []; 64 | 65 | return files.map(filePath => { 66 | const slug = filePath 67 | .replace(new RegExp(`${path.extname(filePath)}$`), '') 68 | .replace(`${pathToContent}/`, '') 69 | .split('/'); 70 | if (slugRewrites && slugRewrites[slug[0]]) { 71 | slug[0] = slugRewrites[slug[0]]; 72 | } 73 | return { filePath, params: { slug } }; 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /next-mdx-relations/tests/createUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { createUtils } from '../src'; 2 | 3 | describe('initialize createUtils', () => { 4 | it('returns util functions', () => { 5 | const utils = createUtils({ content: '/path' }); 6 | expect(utils).toEqual({ 7 | getPaths: expect.any(Function), 8 | getPathsByProp: expect.any(Function), 9 | getPages: expect.any(Function), 10 | getPageProps: expect.any(Function) 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /next-mdx-relations/tests/getPageProps.test.ts: -------------------------------------------------------------------------------- 1 | import { createUtils } from '../src'; 2 | 3 | describe('`getPageProps` functionality', () => { 4 | const { getPageProps } = createUtils({ 5 | content: '/content' 6 | }); 7 | 8 | it('returns props for a given page', async () => { 9 | const pageProps = await getPageProps(['blog', 'post-01']); 10 | expect(pageProps).toEqual(expect.any(Object)); 11 | expect(pageProps?.frontmatter?.title).toEqual('Post 1'); 12 | }); 13 | 14 | it('returns null when page does not exist', async () => { 15 | const pageProps = await getPageProps(['wrong', 'route']); 16 | expect(pageProps).toEqual(null); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /next-mdx-relations/tests/getPages.test.ts: -------------------------------------------------------------------------------- 1 | import { createUtils } from '../src'; 2 | 3 | describe('`getPages` functionality', () => { 4 | const { getPages } = createUtils({ 5 | content: '/content', 6 | sort: { 7 | by: 'frontmatter.title', 8 | order: 'asc' 9 | }, 10 | slugRewrites: { 11 | blog: 'garden' 12 | }, 13 | metaGenerators: { 14 | titleLength: node => node.frontmatter?.title?.length || 0 15 | }, 16 | relationGenerators: { 17 | order: (_, index) => index 18 | } 19 | }); 20 | 21 | it('returns a `getPages` function', () => { 22 | expect(getPages).toEqual(expect.any(Function)); 23 | }); 24 | 25 | it('returns an array of pages', async () => { 26 | const pages = await getPages(); 27 | expect(pages).toEqual(expect.any(Array)); 28 | expect(pages.length).toEqual(3); 29 | }); 30 | 31 | it('returns pages with a file path, params, and content', async () => { 32 | const pages = await getPages(); 33 | pages.forEach(page => { 34 | expect(page).toHaveProperty('filePath'); 35 | expect(page).toHaveProperty('params'); 36 | expect(page).toHaveProperty('content'); 37 | }); 38 | }); 39 | 40 | it('can filter by frontmatter `react`', async () => { 41 | const pages = await getPages({ frontmatter: { tags: 'react' } }); 42 | expect(pages).toEqual(expect.any(Array)); 43 | pages.forEach(page => { 44 | expect(page?.frontmatter?.tags).toEqual( 45 | expect.arrayContaining(['react']) 46 | ); 47 | }); 48 | }); 49 | 50 | it('can filter by frontmatter `random`', async () => { 51 | const pages = await getPages({ frontmatter: { tags: 'random' } }); 52 | expect(pages).toEqual(expect.any(Array)); 53 | pages.forEach(page => { 54 | expect(page?.frontmatter?.tags).toEqual( 55 | expect.arrayContaining(['random']) 56 | ); 57 | }); 58 | }); 59 | 60 | it('returns an empty array when no page matches filter', async () => { 61 | const pages = await getPages({ frontmatter: { tags: 'test' } }); 62 | expect(pages).toEqual(expect.any(Array)); 63 | expect(pages.length).toEqual(0); 64 | }); 65 | 66 | it('creates new metadata', async () => { 67 | const pages = await getPages(); 68 | pages.forEach(page => { 69 | expect(page).toHaveProperty('meta'); 70 | expect(page?.meta).toHaveProperty('titleLength'); 71 | }); 72 | }); 73 | }); 74 | 75 | describe('`getPages` filtering functionality', () => { 76 | const { getPages } = createUtils({ 77 | content: '/content', 78 | sort: { 79 | by: 'frontmatter.title', 80 | order: 'desc' 81 | }, 82 | metaGenerators: { 83 | titleLength: node => node.frontmatter?.title?.length || 0 84 | } 85 | }); 86 | 87 | it('does not have to filter', async () => { 88 | const pages = await getPages(undefined); 89 | expect(pages.length).toEqual(3); 90 | }); 91 | 92 | it('filters by matching arrays', async () => { 93 | const pages = await getPages({ 94 | frontmatter: { tags: ['react', 'nextjs'] } 95 | }); 96 | expect(pages.length).toEqual(1); 97 | }); 98 | 99 | it('filters by checking if string is in array', async () => { 100 | const pages = await getPages({ 101 | frontmatter: { title: ['Post 1'] }, 102 | meta: { titleLength: 6 } 103 | }); 104 | expect(pages.length).toEqual(1); 105 | }); 106 | 107 | it('filters by checking boolean value', async () => { 108 | const pages = await getPages({ 109 | frontmatter: { featured: true } 110 | }); 111 | expect(pages.length).toEqual(1); 112 | }); 113 | 114 | it('filters if values match (usually string)', async () => { 115 | const pages = await getPages({ 116 | frontmatter: { title: 'Post 1' } 117 | }); 118 | expect(pages.length).toEqual(1); 119 | }); 120 | }); 121 | 122 | describe('`getPages` returns an empty array when no content -- ex. empty or missing directory', () => { 123 | const { getPages } = createUtils({ content: '/content/projects' }); 124 | 125 | it('returns an empty array when no content', async () => { 126 | const pages = await getPages(); 127 | expect(pages).toEqual(expect.any(Array)); 128 | expect(pages.length).toEqual(0); 129 | }); 130 | }); 131 | 132 | describe('`getPages` relational generators', () => { 133 | const { getPages } = createUtils({ 134 | content: '/content', 135 | sort: { 136 | by: 'frontmatter.title', 137 | order: 'asc' 138 | }, 139 | slugRewrites: { 140 | blog: 'garden' 141 | }, 142 | metaGenerators: { 143 | titleLength: node => node.frontmatter?.title?.length || 0 144 | }, 145 | relationGenerators: { 146 | order: (_, index) => index, 147 | '[r1, r2]': () => ({ 148 | r1: Math.random(), 149 | r2: Math.random() 150 | }), 151 | object: () => ({ 152 | r3: Math.random() 153 | }), 154 | length: { 155 | transform: nodes => 156 | nodes.sort( 157 | ({ meta: { titleLength: a } }, { meta: { titleLength: b } }) => 158 | b - a 159 | ), 160 | map: (_, i) => i 161 | } 162 | } 163 | }); 164 | 165 | it('adds relations to `meta` of each page', async () => { 166 | const pages = await getPages(); 167 | pages.forEach(page => { 168 | expect(page).toHaveProperty('meta'); 169 | expect(page.meta).toHaveProperty('r1'); 170 | expect(page.meta).toHaveProperty('order'); 171 | }); 172 | }); 173 | 174 | it('adds relations to correct name space (ex. order)', async () => { 175 | const pages = await getPages(); 176 | pages.forEach(page => { 177 | expect(page).toHaveProperty('meta'); 178 | expect(page.meta).toHaveProperty('order'); 179 | expect(page.meta.order).toEqual(expect.any(Number)); 180 | }); 181 | }); 182 | 183 | it('adds multiple relations to correct name space (ex. [r1, r2])', async () => { 184 | const pages = await getPages(); 185 | pages.forEach(page => { 186 | expect(page).toHaveProperty('meta'); 187 | expect(page.meta).toHaveProperty('r1'); 188 | expect(page.meta.r1).toEqual(expect.any(Number)); 189 | }); 190 | }); 191 | 192 | it('adds nested relation to correct name space (ex. object)', async () => { 193 | const pages = await getPages(); 194 | pages.forEach(page => { 195 | expect(page).toHaveProperty('meta'); 196 | expect(page.meta).toHaveProperty('object'); 197 | expect(page.meta.object).toEqual({ r3: expect.any(Number) }); 198 | }); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /next-mdx-relations/tests/getPaths.test.ts: -------------------------------------------------------------------------------- 1 | import { createUtils } from '../src'; 2 | 3 | describe('`getPaths` functionality', () => { 4 | const { getPaths } = createUtils({ content: '/content' }); 5 | 6 | it('returns a `getPaths` function', () => { 7 | expect(getPaths).toEqual(expect.any(Function)); 8 | }); 9 | 10 | it('returns an array of paths', async () => { 11 | const paths = await getPaths(); 12 | expect(paths).toEqual(expect.any(Array)); 13 | expect(paths.length).toBe(3); 14 | }); 15 | 16 | it('returns paths w/ `params` based on filesystem', async () => { 17 | const paths = await getPaths(); 18 | paths.forEach(path => { 19 | expect(path).toHaveProperty('params'); 20 | expect(path.params).toHaveProperty('slug'); 21 | }); 22 | }); 23 | 24 | it('can take an alternative path param', async () => { 25 | const paths = await getPaths('/content/projects'); 26 | expect(paths).toEqual(expect.any(Array)); 27 | expect(paths.length).toBe(0); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /next-mdx-relations/tests/getPathsByProp.test.ts: -------------------------------------------------------------------------------- 1 | import { createUtils } from '../src'; 2 | 3 | describe('`getPageProps` functionality', () => { 4 | const { getPathsByProp } = createUtils({ 5 | content: '/content' 6 | }); 7 | 8 | it('returns a list of pages for a given prop', async () => { 9 | const paths = await getPathsByProp('frontmatter.tags'); 10 | expect(paths).toEqual(expect.any(Array)); 11 | expect(paths.length).toEqual(4); 12 | }); 13 | 14 | it('returns a list of pages for a given prop (string)', async () => { 15 | const paths = await getPathsByProp('frontmatter.title'); 16 | expect(paths).toEqual(expect.any(Array)); 17 | expect(paths.length).toEqual(3); 18 | }); 19 | 20 | it('skips paths when not present on page/frontmatter', async () => { 21 | const paths = await getPathsByProp('frontmatter.secret'); 22 | expect(paths).toEqual(expect.any(Array)); 23 | expect(paths).toEqual(['42']); 24 | }); 25 | 26 | it('returns an empty array when no matching props', async () => { 27 | const paths = await getPathsByProp('frontmatter.one-off'); 28 | expect(paths).toEqual(expect.any(Array)); 29 | expect(paths.length).toEqual(0); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /next-mdx-relations/tests/utilities.test.ts: -------------------------------------------------------------------------------- 1 | import { getPathValues, getValueFromPath } from '../src/utils'; 2 | 3 | describe('getValueFromPath', () => { 4 | it('throws an error if path is neither a string or array of strings', () => { 5 | expect(() => getValueFromPath({}, {})).toThrow(); 6 | }); 7 | }); 8 | 9 | describe('getPathValues', () => { 10 | const testObject = { 11 | a: 24, 12 | b: [1, 2, 3], 13 | deep: { 14 | deeper: [{ test: 24 }], 15 | a: 24 16 | } 17 | }; 18 | 19 | it('gets the path values', () => { 20 | expect(getPathValues(testObject)).toEqual([ 21 | { objectPath: ['a'], value: 24 }, 22 | { objectPath: ['b'], value: [1, 2, 3] }, 23 | { objectPath: ['deep', 'deeper'], value: [{ test: 24 }] }, 24 | { objectPath: ['deep', 'a'], value: 24 } 25 | ]); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /next-mdx-relations/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "baseUrl": ".", 4 | "compilerOptions": { 5 | "isolatedModules": false 6 | }, 7 | "declaration": true, 8 | "include": ["src/**/*.ts"] 9 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-mdx-relations-monorepo", 3 | "private": true, 4 | "workspaces": [ 5 | "ts-example", 6 | "next-mdx-relations" 7 | ], 8 | "scripts": { 9 | "lint": "eslint --fix . && echo 'linted 💯'", 10 | "test": "vitest run", 11 | "testing": "vitest watch --coverage", 12 | "example:dev": "yarn workspace ts-example dev", 13 | "example:build": "yarn workspace ts-example build", 14 | "package:build": "yarn workspace next-mdx-relations build", 15 | "package:release": "yarn workspace next-mdx-relations release" 16 | }, 17 | "dependencies": { 18 | "fast-glob": "3.2.11", 19 | "gray-matter": "4.0.3", 20 | "next-mdx-remote": "4.0.3" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "7.18.5", 24 | "@babel/preset-env": "7.18.2", 25 | "@babel/preset-typescript": "7.17.12", 26 | "@netlify/plugin-nextjs": "4.9.1", 27 | "@types/mock-fs": "4.13.1", 28 | "@typescript-eslint/eslint-plugin": "5.28.0", 29 | "@typescript-eslint/parser": "5.28.0", 30 | "c8": "7.11.3", 31 | "eslint": "8.18.0", 32 | "eslint-config-prettier": "8.5.0", 33 | "eslint-plugin-import": "2.26.0", 34 | "eslint-plugin-prettier": "4.0.0", 35 | "happy-dom": "5.3.1", 36 | "mock-fs": "5.1.2", 37 | "prettier": "2.7.1", 38 | "typescript": "4.7.4", 39 | "vitest": "0.15.1" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":semanticCommits" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /ts-example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /ts-example/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | yarn dev 9 | ``` 10 | 11 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 12 | 13 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 14 | 15 | ## `next-mdx-relations` and typescript 16 | 17 | This project uses `next-mdx-relations` to generate page from `md/x` files. 18 | -------------------------------------------------------------------------------- /ts-example/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 | -------------------------------------------------------------------------------- /ts-example/next-mdx-relations.config.ts: -------------------------------------------------------------------------------- 1 | import markdownLinkExtractor from 'markdown-link-extractor'; 2 | import { createUtils } from 'next-mdx-relations'; 3 | 4 | export const { getPages, getPaths, getPageProps } = createUtils({ 5 | content: '../content', 6 | metaGenerators: { 7 | mentions: node => { 8 | const links = markdownLinkExtractor(node.content); 9 | return links?.filter((l: string) => l[0] === '/'); 10 | } 11 | }, 12 | relationGenerators: { 13 | mentionedIn: (node, _, nodes) => 14 | nodes.filter(n => 15 | n?.meta?.mentions.includes(`/${node.params.slug.join('/')}`) 16 | ), 17 | reverseSort: { 18 | transform: nodes => nodes.reverse(), 19 | map: (node, index, array) => { 20 | const prev = index > 0 ? array[index - 1] : null; 21 | const next = index < array.length - 1 ? array[index + 1] : null; 22 | return { 23 | prev, 24 | next 25 | }; 26 | } 27 | } 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /ts-example/next.config.js: -------------------------------------------------------------------------------- 1 | const withTM = require('next-transpile-modules')([]); 2 | 3 | module.exports = withTM({ 4 | reactStrictMode: true 5 | }); 6 | -------------------------------------------------------------------------------- /ts-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "markdown-link-extractor": "4.0.1", 13 | "next": "12.1.6", 14 | "next-mdx-relations": "^0.0.16", 15 | "next-transpile-modules": "9.0.0", 16 | "react": "18.2.0", 17 | "react-dom": "18.2.0" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "18.0.14", 21 | "eslint": "8.18.0", 22 | "eslint-config-next": "12.1.6", 23 | "typescript": "4.7.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ts-example/pages/[...slug].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { MDXRemote } from 'next-mdx-remote'; 3 | import Link from 'next/link'; 4 | import { MDXPage, Params } from 'next-mdx-relations'; 5 | import styles from '../styles/Home.module.css'; 6 | import { getPaths, getPageProps } from '../next-mdx-relations.config'; 7 | 8 | function Slug({ mdx, ...pageNode }: MDXPage) { 9 | const { 10 | frontmatter, 11 | meta: { mentionedIn } 12 | } = pageNode; 13 | const { 14 | author, 15 | tags, 16 | title 17 | }: { author: string; tags: string[]; title: string } = frontmatter; 18 | return ( 19 | <> 20 |

{title}

21 |

by: {author}

22 | 23 | tags:{' '} 24 | {tags.map(t => ( 25 | 26 | {t} 27 | 28 | ))} 29 | 30 |
31 | 32 | {mentionedIn.length > 0 && ( 33 | <> 34 |
35 |

mentioned in

36 | {mentionedIn.map( 37 | ({ frontmatter: { title, tags }, params: { slug } }: MDXPage) => ( 38 |
39 | 40 | {title} 41 | 42 | 43 | tags:{' '} 44 | {tags.map((t: string) => ( 45 | 46 | {t} 47 | 48 | ))} 49 | 50 |
51 | ) 52 | )} 53 | 54 | )} 55 | 56 | ); 57 | } 58 | 59 | export async function getStaticProps({ params: { slug } }: Params) { 60 | const props = await getPageProps(slug); 61 | 62 | return { 63 | props 64 | }; 65 | } 66 | 67 | export async function getStaticPaths() { 68 | const paths = await getPaths(); 69 | 70 | return { 71 | paths, 72 | fallback: false 73 | }; 74 | } 75 | 76 | export default Slug; 77 | -------------------------------------------------------------------------------- /ts-example/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import Link from 'next/link'; 3 | import type { AppProps } from 'next/app'; 4 | 5 | function MyApp({ Component, pageProps }: AppProps) { 6 | return ( 7 |
8 |
9 | 10 | 11 | {''} 12 | 13 | 14 | 22 |
23 |
24 | 25 |
26 | 34 |
35 | ); 36 | } 37 | export default MyApp; 38 | -------------------------------------------------------------------------------- /ts-example/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default function About() { 4 | return ( 5 | <> 6 |

next-mdx-relations

7 |

I have some md/x files. I want to draw relations between them.

8 | 9 |

10 | next-mdx-relations is a small suite of utilities for statically 11 | generating pages for next.js from md and mdx. 12 | This site provides an example by adding a custom 'mentionedIn' 13 | attribute to posts or pages that are mentioned in other posts or pages. 14 |

15 | 16 |

17 | For more details and full documentation, checkout the{' '} 18 | github 19 |

20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /ts-example/pages/blog.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | import styles from '../styles/Home.module.css'; 4 | import { Page } from 'next-mdx-relations'; 5 | import { getPages } from '../next-mdx-relations.config'; 6 | 7 | export default function Blog({ posts }: { posts: Page[] }) { 8 | return ( 9 | <> 10 |

blog

11 |

A list of all posts

12 | {posts && 13 | posts.map(({ frontmatter: { title, tags }, params: { slug } }) => ( 14 |
15 | 16 | {title} 17 | 18 | 19 | tags:{' '} 20 | {tags.map((t: string) => ( 21 | 22 | {t} 23 | 24 | ))} 25 | 26 |
27 | ))} 28 | 29 | ); 30 | } 31 | 32 | export async function getStaticProps() { 33 | const posts: Page[] = await getPages(); 34 | 35 | return { 36 | props: { 37 | posts 38 | } 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /ts-example/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | import styles from '../styles/Home.module.css'; 4 | import { Page } from 'next-mdx-relations'; 5 | import { getPages } from '../next-mdx-relations.config'; 6 | 7 | export default function Home({ posts }: { posts: Page[] }) { 8 | return ( 9 | <> 10 |

next-mdx-relations

11 |

I have some md/x files. I want to draw relations between them.

12 |
13 |

featured posts

14 | {posts && 15 | posts.map(({ frontmatter: { title, tags }, params: { slug } }) => ( 16 |
17 | 18 | {title} 19 | 20 | 21 | tags:{' '} 22 | {tags.map((t: string) => ( 23 | 24 | {t} 25 | 26 | ))} 27 | 28 |
29 | ))} 30 | 31 | ); 32 | } 33 | 34 | export async function getStaticProps() { 35 | const posts: Page[] = await getPages({ frontmatter: { featured: true } }); 36 | 37 | return { 38 | props: { 39 | posts 40 | } 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /ts-example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inadeqtfuturs/next-mdx-relations/f99d2f6a91c7311f0a856323e2ebbf638fec51ad/ts-example/public/favicon.ico -------------------------------------------------------------------------------- /ts-example/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /ts-example/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | flex: 1; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .postWrapper { 8 | display: flex; 9 | flex-direction: column; 10 | padding: 1rem; 11 | border-radius: 16px; 12 | margin: 0 -1rem 1rem; 13 | } 14 | 15 | .postWrapper:hover, 16 | .postWrapper:focus-within { 17 | background: rgba(0, 0, 0, 0.025); 18 | } 19 | 20 | a.link { 21 | font-size: 1.5rem; 22 | margin-bottom: 1.5rem; 23 | } 24 | 25 | span.tag { 26 | border-radius: 4px; 27 | padding: 4px 8px; 28 | margin: 0 8px 0 0; 29 | background: rgba(0, 0, 0, 0.03); 30 | white-space: nowrap; 31 | } 32 | -------------------------------------------------------------------------------- /ts-example/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | .container { 10 | max-width: 768px; 11 | margin: 2rem auto; 12 | padding: 0 1rem; 13 | min-height: calc(100vh - 4rem); 14 | display: grid; 15 | grid-template-rows: min-content 1fr min-content; 16 | } 17 | 18 | footer, header { 19 | display: flex; 20 | align-items: center; 21 | justify-content: space-between; 22 | } 23 | 24 | a.menuItem { 25 | text-decoration: none; 26 | border-radius: 4px; 27 | padding: 6px 12px; 28 | margin: 0 4px 0 0; 29 | white-space: nowrap; 30 | } 31 | 32 | a.menuItem:hover, 33 | a.menuItem:focus { 34 | background: rgba(0,0,0,0.05); 35 | } 36 | 37 | a.plantLink, 38 | a.brand { 39 | padding: 2px 4px; 40 | border-radius: 8px; 41 | transition: background 0.3s; 42 | text-decoration: none; 43 | } 44 | 45 | a.brand { 46 | background: rgba(0,0,0,0.05); 47 | height: 36px; 48 | width: 36px; 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | } 53 | 54 | a.plantLink:hover, 55 | a.plantLink:focus, 56 | a.brand:hover, 57 | a.brand:focus { 58 | background: rgba(0,0,0,0.05); 59 | } 60 | 61 | a { 62 | color: inherit; 63 | text-decoration: none; 64 | text-decoration: underline; 65 | text-decoration-color: rgba(0, 0, 0, 0.25); 66 | text-underline-offset: 0.25rem; 67 | } 68 | 69 | * { 70 | box-sizing: border-box; 71 | } 72 | 73 | hr { 74 | margin: 1.25rem 0; 75 | } 76 | 77 | code { 78 | font-size: 1rem; 79 | } 80 | -------------------------------------------------------------------------------- /ts-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "typeRoots": [ "./types", "./node_modules/@types"], 17 | "incremental": true 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules", "types"] 21 | } 22 | -------------------------------------------------------------------------------- /ts-example/types/markdown-link-extractor/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'markdown-link-extractor'; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "jsx": "react", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "target": "ES2019" 10 | } 11 | } -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | environment: 'happy-dom' 9 | } 10 | }); 11 | --------------------------------------------------------------------------------