├── .gitignore ├── README.md ├── __tests__ ├── __snapshots__ │ ├── index.js.snap │ └── search.js.snap ├── index.js └── search.js ├── index.d.ts ├── index.js ├── next.js ├── package.json ├── search.js ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── toc.js.snap │ └── toc.js ├── flatten.js ├── parser.js └── toc.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | node_modules 4 | yarn-error.log 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Generate Guidebook 2 | 3 | A utility for generating a tutorial guide. Used in: 4 | 5 | - https://javascript.express 6 | - http://react.express 7 | - http://www.reactnativeexpress.com/ 8 | 9 | ## Example 10 | 11 | ```js 12 | const generateGuidebook = require('generate-guidebook') 13 | 14 | const guidebook = generateGuidebook('./pages') 15 | ``` 16 | 17 | ## Next plugin 18 | 19 | ```js 20 | const withGuidebook = require('generate-guidebook/next') 21 | 22 | // These are the default options 23 | module.exports = withGuidebook({ 24 | guidebookDirectory = './pages', 25 | guidebookModulePath = './guide.js', 26 | }) 27 | ``` 28 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/index.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`index handles order in config.json 1`] = ` 4 | Object { 5 | "children": Array [ 6 | Object { 7 | "children": Array [], 8 | "file": "b.mdx", 9 | "headings": Array [], 10 | "id": 1, 11 | "next": "a", 12 | "parent": "", 13 | "previous": "", 14 | "slug": "b", 15 | "subtitle": undefined, 16 | "title": "B", 17 | }, 18 | Object { 19 | "children": Array [], 20 | "file": "a.mdx", 21 | "headings": Array [], 22 | "id": 2, 23 | "next": undefined, 24 | "parent": "", 25 | "previous": "b", 26 | "slug": "a", 27 | "subtitle": undefined, 28 | "title": "A", 29 | }, 30 | ], 31 | "file": "index.mdx", 32 | "headings": Array [], 33 | "id": 0, 34 | "next": "b", 35 | "slug": "", 36 | "subtitle": undefined, 37 | "title": "Index", 38 | } 39 | `; 40 | 41 | exports[`index handles order in nested config.json 1`] = ` 42 | Object { 43 | "children": Array [ 44 | Object { 45 | "children": Array [ 46 | Object { 47 | "children": Array [], 48 | "file": "1.mdx", 49 | "headings": Array [], 50 | "id": 2, 51 | "next": "hooks", 52 | "parent": "a", 53 | "previous": "a", 54 | "slug": "a/1", 55 | "subtitle": undefined, 56 | "title": "1", 57 | }, 58 | ], 59 | "file": "a.mdx", 60 | "headings": Array [], 61 | "id": 1, 62 | "next": "a/1", 63 | "parent": "", 64 | "previous": "", 65 | "slug": "a", 66 | "subtitle": undefined, 67 | "title": "A", 68 | }, 69 | Object { 70 | "children": Array [ 71 | Object { 72 | "children": Array [], 73 | "file": "usestate.mdx", 74 | "headings": Array [ 75 | Object { 76 | "level": 1, 77 | "title": "useState", 78 | "url": "#usestate", 79 | }, 80 | ], 81 | "id": 4, 82 | "next": "hooks/usereducer", 83 | "parent": "hooks", 84 | "previous": "hooks", 85 | "slug": "hooks/usestate", 86 | "subtitle": undefined, 87 | "title": "useState", 88 | }, 89 | Object { 90 | "children": Array [], 91 | "file": "usereducer.mdx", 92 | "headings": Array [], 93 | "id": 5, 94 | "next": "hooks/useeffect", 95 | "parent": "hooks", 96 | "previous": "hooks/usestate", 97 | "slug": "hooks/usereducer", 98 | "subtitle": undefined, 99 | "title": "Usereducer", 100 | }, 101 | Object { 102 | "children": Array [], 103 | "file": "useeffect.mdx", 104 | "headings": Array [], 105 | "id": 6, 106 | "next": "hooks/useref", 107 | "parent": "hooks", 108 | "previous": "hooks/usereducer", 109 | "slug": "hooks/useeffect", 110 | "subtitle": undefined, 111 | "title": "Useeffect", 112 | }, 113 | Object { 114 | "children": Array [], 115 | "file": "useref.mdx", 116 | "headings": Array [], 117 | "id": 7, 118 | "next": "hooks/usecontext", 119 | "parent": "hooks", 120 | "previous": "hooks/useeffect", 121 | "slug": "hooks/useref", 122 | "subtitle": undefined, 123 | "title": "Useref", 124 | }, 125 | Object { 126 | "children": Array [], 127 | "file": "usecontext.mdx", 128 | "headings": Array [], 129 | "id": 8, 130 | "next": "hooks/custom_hooks", 131 | "parent": "hooks", 132 | "previous": "hooks/useref", 133 | "slug": "hooks/usecontext", 134 | "subtitle": undefined, 135 | "title": "Usecontext", 136 | }, 137 | Object { 138 | "children": Array [], 139 | "file": "custom_hooks.mdx", 140 | "headings": Array [], 141 | "id": 9, 142 | "next": undefined, 143 | "parent": "hooks", 144 | "previous": "hooks/usecontext", 145 | "slug": "hooks/custom_hooks", 146 | "subtitle": undefined, 147 | "title": "Custom Hooks", 148 | }, 149 | ], 150 | "file": "hooks.mdx", 151 | "headings": Array [], 152 | "id": 3, 153 | "next": "hooks/usestate", 154 | "parent": "", 155 | "previous": "a/1", 156 | "slug": "hooks", 157 | "subtitle": undefined, 158 | "title": "Hooks", 159 | }, 160 | ], 161 | "file": "index.mdx", 162 | "headings": Array [], 163 | "id": 0, 164 | "next": "a", 165 | "slug": "", 166 | "subtitle": undefined, 167 | "title": "Index", 168 | } 169 | `; 170 | 171 | exports[`index hidden handles hidden directory in frontmatter 1`] = ` 172 | Object { 173 | "children": Array [], 174 | "file": "index.mdx", 175 | "headings": Array [], 176 | "id": 0, 177 | "next": undefined, 178 | "slug": "", 179 | "subtitle": undefined, 180 | "title": "Index", 181 | } 182 | `; 183 | 184 | exports[`index hidden handles hidden flag in frontmatter 1`] = ` 185 | Object { 186 | "children": Array [ 187 | Object { 188 | "children": Array [], 189 | "file": "a.mdx", 190 | "headings": Array [], 191 | "id": 1, 192 | "next": undefined, 193 | "parent": "", 194 | "previous": "", 195 | "slug": "a", 196 | "subtitle": undefined, 197 | "title": "A", 198 | }, 199 | ], 200 | "file": "index.mdx", 201 | "headings": Array [], 202 | "id": 0, 203 | "next": "a", 204 | "slug": "", 205 | "subtitle": undefined, 206 | "title": "Index", 207 | } 208 | `; 209 | 210 | exports[`index hidden handles hidden flag in frontmatter 2`] = ` 211 | Object { 212 | "children": Array [], 213 | "file": "index.mdx", 214 | "headings": Array [], 215 | "id": 0, 216 | "next": undefined, 217 | "slug": "", 218 | "subtitle": undefined, 219 | "title": "Index", 220 | } 221 | `; 222 | 223 | exports[`index hidden handles hidden flag in frontmatter 3`] = ` 224 | Object { 225 | "children": Array [ 226 | Object { 227 | "children": Array [], 228 | "file": "a.mdx", 229 | "headings": Array [], 230 | "id": 1, 231 | "next": undefined, 232 | "parent": "", 233 | "previous": "", 234 | "slug": "a", 235 | "subtitle": undefined, 236 | "title": "A", 237 | }, 238 | ], 239 | "file": "index.mdx", 240 | "headings": Array [], 241 | "id": 0, 242 | "next": "a", 243 | "slug": "", 244 | "subtitle": undefined, 245 | "title": "Index", 246 | } 247 | `; 248 | 249 | exports[`index reads doubly nested directories 1`] = ` 250 | Object { 251 | "children": Array [ 252 | Object { 253 | "children": Array [ 254 | Object { 255 | "children": Array [], 256 | "file": "1.mdx", 257 | "headings": Array [], 258 | "id": 2, 259 | "next": "a/2", 260 | "parent": "a", 261 | "previous": "a", 262 | "slug": "a/1", 263 | "subtitle": undefined, 264 | "title": "1", 265 | }, 266 | Object { 267 | "children": Array [ 268 | Object { 269 | "children": Array [], 270 | "file": "i.mdx", 271 | "headings": Array [], 272 | "id": 4, 273 | "next": "a/2/j", 274 | "parent": "a/2", 275 | "previous": "a/2", 276 | "slug": "a/2/i", 277 | "subtitle": undefined, 278 | "title": "I", 279 | }, 280 | Object { 281 | "children": Array [], 282 | "file": "j.mdx", 283 | "headings": Array [], 284 | "id": 5, 285 | "next": "b", 286 | "parent": "a/2", 287 | "previous": "a/2/i", 288 | "slug": "a/2/j", 289 | "subtitle": undefined, 290 | "title": "J", 291 | }, 292 | ], 293 | "file": "2.mdx", 294 | "headings": Array [], 295 | "id": 3, 296 | "next": "a/2/i", 297 | "parent": "a", 298 | "previous": "a/1", 299 | "slug": "a/2", 300 | "subtitle": undefined, 301 | "title": "2", 302 | }, 303 | ], 304 | "file": "a.mdx", 305 | "headings": Array [], 306 | "id": 1, 307 | "next": "a/1", 308 | "parent": "", 309 | "previous": "", 310 | "slug": "a", 311 | "subtitle": undefined, 312 | "title": "A", 313 | }, 314 | Object { 315 | "children": Array [ 316 | Object { 317 | "children": Array [], 318 | "file": "1.mdx", 319 | "headings": Array [], 320 | "id": 7, 321 | "next": undefined, 322 | "parent": "b", 323 | "previous": "b", 324 | "slug": "b/1", 325 | "subtitle": undefined, 326 | "title": "1", 327 | }, 328 | ], 329 | "file": "b.mdx", 330 | "headings": Array [], 331 | "id": 6, 332 | "next": "b/1", 333 | "parent": "", 334 | "previous": "a/2/j", 335 | "slug": "b", 336 | "subtitle": undefined, 337 | "title": "B", 338 | }, 339 | ], 340 | "file": "index.mdx", 341 | "headings": Array [], 342 | "id": 0, 343 | "next": "a", 344 | "slug": "", 345 | "subtitle": undefined, 346 | "title": "Index", 347 | } 348 | `; 349 | 350 | exports[`index reads nested directories 1`] = ` 351 | Object { 352 | "children": Array [ 353 | Object { 354 | "children": Array [ 355 | Object { 356 | "children": Array [], 357 | "file": "1.mdx", 358 | "headings": Array [], 359 | "id": 2, 360 | "next": "a/2", 361 | "parent": "a", 362 | "previous": "a", 363 | "slug": "a/1", 364 | "subtitle": undefined, 365 | "title": "1", 366 | }, 367 | Object { 368 | "children": Array [], 369 | "file": "2.mdx", 370 | "headings": Array [], 371 | "id": 3, 372 | "next": "b", 373 | "parent": "a", 374 | "previous": "a/1", 375 | "slug": "a/2", 376 | "subtitle": undefined, 377 | "title": "2", 378 | }, 379 | ], 380 | "file": "a.mdx", 381 | "headings": Array [], 382 | "id": 1, 383 | "next": "a/1", 384 | "parent": "", 385 | "previous": "", 386 | "slug": "a", 387 | "subtitle": undefined, 388 | "title": "A", 389 | }, 390 | Object { 391 | "children": Array [ 392 | Object { 393 | "children": Array [], 394 | "file": "1.mdx", 395 | "headings": Array [], 396 | "id": 5, 397 | "next": undefined, 398 | "parent": "b", 399 | "previous": "b", 400 | "slug": "b/1", 401 | "subtitle": undefined, 402 | "title": "1", 403 | }, 404 | ], 405 | "file": "b.mdx", 406 | "headings": Array [], 407 | "id": 4, 408 | "next": "b/1", 409 | "parent": "", 410 | "previous": "a/2", 411 | "slug": "b", 412 | "subtitle": undefined, 413 | "title": "B", 414 | }, 415 | ], 416 | "file": "index.mdx", 417 | "headings": Array [], 418 | "id": 0, 419 | "next": "a", 420 | "slug": "", 421 | "subtitle": undefined, 422 | "title": "Index", 423 | } 424 | `; 425 | 426 | exports[`index reads top-level directory 1`] = ` 427 | Object { 428 | "children": Array [ 429 | Object { 430 | "children": Array [], 431 | "file": "a.mdx", 432 | "headings": Array [], 433 | "id": 1, 434 | "next": "b", 435 | "parent": "", 436 | "previous": "", 437 | "slug": "a", 438 | "subtitle": undefined, 439 | "title": "A", 440 | }, 441 | Object { 442 | "children": Array [], 443 | "file": "b.mdx", 444 | "headings": Array [], 445 | "id": 2, 446 | "next": undefined, 447 | "parent": "", 448 | "previous": "a", 449 | "slug": "b", 450 | "subtitle": undefined, 451 | "title": "B", 452 | }, 453 | ], 454 | "file": "index.mdx", 455 | "headings": Array [], 456 | "id": 0, 457 | "next": "a", 458 | "slug": "", 459 | "subtitle": undefined, 460 | "title": "Index", 461 | } 462 | `; 463 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/search.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`search builds a search index 1`] = `"[[{\\"h\\":[0],\\"he\\":[0],\\"hel\\":[0],\\"hell\\":[0],\\"hello\\":[0],\\"f\\":[1,2],\\"fo\\":[1,2],\\"foo\\":[1,2]},{},{},{},{},{},{},{},{}],{},[\\"@0\\",\\"@1\\",\\"@2\\"]]"`; 4 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | const { Volume, createFsFromVolume } = require('memfs') 2 | const scan = require('..') 3 | 4 | function createFs(fileTree) { 5 | const volume = Volume.fromNestedJSON(fileTree, '/') 6 | const memfs = createFsFromVolume(volume) 7 | return memfs 8 | } 9 | 10 | describe('index', () => { 11 | it('converts an index file', () => { 12 | const fs = createFs({ 13 | pages: { 14 | 'index.mdx': '', 15 | }, 16 | }) 17 | 18 | const result = scan('/pages', undefined, fs) 19 | 20 | expect(result).toEqual({ 21 | id: 0, 22 | file: 'index.mdx', 23 | slug: '', 24 | title: 'Index', 25 | subtitle: undefined, 26 | children: [], 27 | next: undefined, 28 | headings: [], 29 | }) 30 | }) 31 | 32 | it('reads frontmatter', () => { 33 | const fs = createFs({ 34 | pages: { 35 | 'index.mdx': `--- 36 | title: foo 37 | subtitle: bar 38 | author: 39 | name: "@dvnabbott" 40 | url: "https://twitter.com/dvnabbott" 41 | --- 42 | 43 | # Content`, 44 | }, 45 | }) 46 | 47 | const result = scan('/pages', undefined, fs) 48 | 49 | expect(result).toEqual({ 50 | id: 0, 51 | file: 'index.mdx', 52 | slug: '', 53 | title: 'foo', 54 | subtitle: 'bar', 55 | author: { 56 | name: '@dvnabbott', 57 | url: 'https://twitter.com/dvnabbott', 58 | }, 59 | children: [], 60 | next: undefined, 61 | headings: [ 62 | { 63 | level: 1, 64 | title: 'Content', 65 | url: '#content', 66 | }, 67 | ], 68 | }) 69 | }) 70 | 71 | it('supports variables in frontmatter', () => { 72 | const fs = createFs({ 73 | pages: { 74 | 'index.mdx': `--- 75 | title: \${VARIABLE} 76 | --- 77 | 78 | # Content`, 79 | }, 80 | }) 81 | 82 | const result = scan('/pages', { VARIABLE: 'Hello' }, fs) 83 | 84 | expect(result).toEqual({ 85 | id: 0, 86 | file: 'index.mdx', 87 | slug: '', 88 | title: 'Hello', 89 | subtitle: undefined, 90 | children: [], 91 | next: undefined, 92 | headings: [ 93 | { 94 | level: 1, 95 | title: 'Content', 96 | url: '#content', 97 | }, 98 | ], 99 | }) 100 | }) 101 | 102 | it('handles order in config.json', () => { 103 | const fs = createFs({ 104 | pages: { 105 | 'index.mdx': '', 106 | 'a.mdx': '', 107 | 'b.mdx': '', 108 | 'config.json': JSON.stringify({ 109 | order: ['b', 'a'], 110 | }), 111 | }, 112 | }) 113 | 114 | const result = scan('/pages', undefined, fs) 115 | 116 | expect(result).toMatchSnapshot() 117 | }) 118 | 119 | it('reads top-level directory', () => { 120 | const fs = createFs({ 121 | pages: { 122 | 'index.mdx': '', 123 | 'a.mdx': '', 124 | 'b.mdx': '', 125 | }, 126 | }) 127 | 128 | const result = scan('/pages', undefined, fs) 129 | 130 | expect(result).toMatchSnapshot() 131 | }) 132 | 133 | it('reads nested directories', () => { 134 | const fs = createFs({ 135 | pages: { 136 | 'index.mdx': '', 137 | 'a.mdx': '', 138 | a: { 139 | '1.mdx': '', 140 | '2.mdx': '', 141 | }, 142 | 'b.mdx': '', 143 | b: { 144 | '1.mdx': '', 145 | }, 146 | }, 147 | }) 148 | 149 | const result = scan('/pages', undefined, fs) 150 | 151 | expect(result).toMatchSnapshot() 152 | }) 153 | 154 | it('reads doubly nested directories', () => { 155 | const fs = createFs({ 156 | pages: { 157 | 'index.mdx': '', 158 | 'a.mdx': '', 159 | a: { 160 | '1.mdx': '', 161 | '2.mdx': '', 162 | 2: { 163 | 'i.mdx': '', 164 | 'j.mdx': '', 165 | }, 166 | }, 167 | 'b.mdx': '', 168 | b: { 169 | '1.mdx': '', 170 | }, 171 | }, 172 | }) 173 | 174 | const result = scan('/pages', undefined, fs) 175 | 176 | expect(result).toMatchSnapshot() 177 | }) 178 | 179 | it('handles order in nested config.json', () => { 180 | const fs = createFs({ 181 | pages: { 182 | 'index.mdx': '', 183 | 'a.mdx': '', 184 | a: { 185 | '1.mdx': '', 186 | }, 187 | 'hooks.mdx': '', 188 | hooks: { 189 | 'custom_hooks.mdx': '', 190 | 'usecontext.mdx': '', 191 | 'useeffect.mdx': '', 192 | 'usereducer.mdx': '', 193 | 'useref.mdx': '', 194 | 'usestate.mdx': `--- 195 | title: useState 196 | --- 197 | 198 | # useState`, 199 | 'config.json': JSON.stringify({ 200 | order: [ 201 | 'usestate', 202 | 'usereducer', 203 | 'useeffect', 204 | 'useref', 205 | 'usecontext', 206 | 'custom_hooks', 207 | ], 208 | }), 209 | }, 210 | 'config.json': JSON.stringify({ 211 | order: ['a', 'hooks'], 212 | }), 213 | }, 214 | }) 215 | 216 | const result = scan('/pages', undefined, fs) 217 | 218 | expect(result).toMatchSnapshot() 219 | }) 220 | 221 | describe('hidden', () => { 222 | it('handles hidden flag in frontmatter', () => { 223 | const fs = createFs({ 224 | pages: { 225 | 'index.mdx': '', 226 | 'a.mdx': '', 227 | 'b.mdx': `--- 228 | hidden: true 229 | ---`, 230 | }, 231 | }) 232 | 233 | const result = scan('/pages', undefined, fs) 234 | 235 | expect(result).toMatchSnapshot() 236 | }) 237 | 238 | it('handles hidden directory in frontmatter', () => { 239 | const fs = createFs({ 240 | pages: { 241 | 'index.mdx': '', 242 | 'a.mdx': `--- 243 | hidden: true 244 | ---`, 245 | a: { 246 | '1.mdx': '', 247 | }, 248 | }, 249 | }) 250 | 251 | const result = scan('/pages', undefined, fs) 252 | 253 | expect(result).toMatchSnapshot() 254 | }) 255 | 256 | it('handles hidden flag in frontmatter', () => { 257 | const fs = createFs({ 258 | pages: { 259 | 'index.mdx': '', 260 | 'a.mdx': `--- 261 | hidden: TEST_VARIABLE 262 | ---`, 263 | }, 264 | }) 265 | 266 | const result = scan('/pages', { TEST_VARIABLE: true }, fs) 267 | 268 | expect(result).toMatchSnapshot() 269 | }) 270 | 271 | it('handles hidden flag in frontmatter', () => { 272 | const fs = createFs({ 273 | pages: { 274 | 'index.mdx': '', 275 | 'a.mdx': `--- 276 | hidden: UNDEFINED 277 | ---`, 278 | }, 279 | }) 280 | 281 | const result = scan('/pages', undefined, fs) 282 | 283 | expect(result).toMatchSnapshot() 284 | }) 285 | }) 286 | }) 287 | -------------------------------------------------------------------------------- /__tests__/search.js: -------------------------------------------------------------------------------- 1 | const { Volume, createFsFromVolume } = require('memfs') 2 | const scan = require('..') 3 | const { createDocuments, buildIndex, exportIndex } = require('../search') 4 | 5 | function createFs(fileTree) { 6 | const volume = Volume.fromNestedJSON(fileTree, '/') 7 | const memfs = createFsFromVolume(volume) 8 | return memfs 9 | } 10 | 11 | describe('search', () => { 12 | it('builds a search index', () => { 13 | const directory = '/pages' 14 | const fs = createFs({ 15 | pages: { 16 | 'index.mdx': 'hello', 17 | 'a.mdx': 'foo', 18 | a: { 19 | '1.mdx': 'foo', 20 | }, 21 | }, 22 | }) 23 | 24 | const root = scan(directory, undefined, fs) 25 | const documents = createDocuments(directory, root, fs) 26 | const index = buildIndex(documents) 27 | 28 | expect(index.search('hello')).toEqual([0]) 29 | expect(index.search('fo')).toEqual([1, 2]) 30 | 31 | expect(exportIndex(index)).toMatchSnapshot() 32 | }) 33 | 34 | it('indexes titles', () => { 35 | const directory = '/pages' 36 | const fs = createFs({ 37 | pages: { 38 | 'index.mdx': '# hello', 39 | }, 40 | }) 41 | 42 | const root = scan(directory, undefined, fs) 43 | const documents = createDocuments(directory, root, fs) 44 | const index = buildIndex(documents) 45 | 46 | expect(index.search('hello')).toEqual([0]) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export type Author = { 2 | name: string 3 | url?: string 4 | } 5 | 6 | export type TreeNode = { 7 | id: number 8 | file: string 9 | title: string 10 | subtitle?: string 11 | slug: string 12 | parent?: string 13 | previous?: string 14 | next?: string 15 | author?: Author 16 | children: TreeNode[] 17 | headings: HeadingNode[] 18 | } 19 | 20 | export type HeadingNode = { 21 | level: number 22 | title: string 23 | url: string 24 | } 25 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const matter = require('gray-matter') 3 | const toc = require('./src/toc') 4 | 5 | /** 6 | * @typedef { import('.').TreeNode } TreeNode 7 | */ 8 | 9 | /** 10 | * @function 11 | * @template T 12 | * @param {T | null | false | undefined} input 13 | * @returns {T[]} 14 | */ 15 | function compact(input) { 16 | let output = [] 17 | 18 | for (let value of input) { 19 | if (typeof value !== 'undefined' && value !== false && value !== null) { 20 | output.push(value) 21 | } 22 | } 23 | 24 | return output 25 | } 26 | 27 | /** 28 | * Replace variables in the string template. 29 | * 30 | * @param {template} string 31 | * @param {any} variables 32 | * @returns {string} 33 | */ 34 | function replaceTemplate(template, variables) { 35 | return template.replace(/\$\{(.*?)\}/, (match, group1) => { 36 | if (typeof variables === 'object' && variables && group1 in variables) { 37 | return variables[group1].toString() 38 | } 39 | 40 | return `(Undefined variable: ${group1})` 41 | }) 42 | } 43 | 44 | /** 45 | * @param {string} string 46 | * @returns {string} 47 | */ 48 | function formatSlug(string) { 49 | return string.replace(/ /g, '_').toLowerCase() 50 | } 51 | 52 | /** 53 | * @param {string} string 54 | * @returns {string} 55 | */ 56 | function formatTitle(string) { 57 | function titleCase(component) { 58 | if (component === 'or' || component === 'and') return component 59 | 60 | return component.slice(0, 1).toUpperCase() + component.slice(1) 61 | } 62 | 63 | return string.split('_').map(titleCase).join(' ') 64 | } 65 | 66 | /** 67 | * @typedef {{ order?: number, title?: string, subtitle?: string }} FrontMatter 68 | */ 69 | 70 | /** 71 | * @param {string} filePath 72 | * @returns {{ data: FrontMatter, content: string }} 73 | */ 74 | function read(filePath, fs) { 75 | return matter(fs.readFileSync(filePath, 'utf8')) 76 | } 77 | 78 | /** 79 | * @param {string} directoryPath 80 | * @param {string[]} files 81 | * @returns {string[]} 82 | */ 83 | function sortFiles(directoryPath, files, fs) { 84 | let orderJSON = {} 85 | 86 | try { 87 | const data = fs.readFileSync(path.join(directoryPath, 'config.json')) 88 | const config = JSON.parse(data) 89 | orderJSON = (config.order || []).reduce((result, item, index) => { 90 | result[item] = index + 1 91 | return result 92 | }, {}) 93 | } catch (e) { 94 | // Pass 95 | } 96 | 97 | return files 98 | .map((file, index) => { 99 | const order = 100 | orderJSON[path.basename(file, '.mdx')] || 101 | read(path.join(directoryPath, file), fs).data.order || 102 | 10000 + index 103 | 104 | return { file, order } 105 | }) 106 | .sort((a, b) => a.order - b.order) 107 | .map((obj) => obj.file) 108 | } 109 | 110 | /** 111 | * @typedef {{ fs: import('fs'), id: number }} Context 112 | */ 113 | 114 | /** 115 | * @param {string} rootPath 116 | * @param {string[]} pathComponents 117 | * @param {context} Context 118 | * @returns {TreeNode[]} 119 | */ 120 | function readTree(rootPath, pathComponents, context) { 121 | const { fs } = context 122 | const files = fs.readdirSync(rootPath) 123 | 124 | const pages = sortFiles( 125 | rootPath, 126 | files.filter((f) => f.endsWith('.mdx') && f !== 'index.mdx'), 127 | fs 128 | ) 129 | 130 | const directories = files.filter((f) => 131 | fs.statSync(path.join(rootPath, f)).isDirectory() 132 | ) 133 | 134 | return compact( 135 | pages.map((file) => { 136 | const basename = path.basename(file, '.mdx') 137 | const components = [...pathComponents, basename] 138 | 139 | const { data: frontmatter, content } = read(path.join(rootPath, file), fs) 140 | 141 | if (frontmatter.hidden === true) return 142 | 143 | if ( 144 | typeof frontmatter.hidden === 'string' && 145 | typeof context.variables === 'object' && 146 | context.variables && 147 | context.variables[frontmatter.hidden] 148 | ) { 149 | return 150 | } 151 | 152 | return { 153 | id: context.id++, 154 | file, 155 | title: frontmatter.title 156 | ? replaceTemplate(frontmatter.title, context.variables) 157 | : formatTitle(basename), 158 | subtitle: frontmatter.subtitle 159 | ? replaceTemplate(frontmatter.subtitle, context.variables) 160 | : undefined, 161 | slug: components.map(formatSlug).join('/'), 162 | parent: components.slice(0, -1).map(formatSlug).join('/'), 163 | children: directories.includes(basename) 164 | ? readTree(path.join(rootPath, basename), components, context) 165 | : [], 166 | headings: toc(content), 167 | ...(frontmatter.author && { 168 | author: { 169 | name: frontmatter.author.name, 170 | url: frontmatter.author.url, 171 | }, 172 | }), 173 | } 174 | }) 175 | ) 176 | } 177 | 178 | /** 179 | * @param {TreeNode[]} nodes 180 | * @param {string} previous 181 | * @param {string | undefined} next 182 | * @returns {TreeNode[]} 183 | */ 184 | function connectNodes(nodes, previous, next) { 185 | nodes.forEach((node, index) => { 186 | const isFirst = index === 0 187 | const isLast = index === nodes.length - 1 188 | 189 | if (isFirst) { 190 | node.previous = previous 191 | } else { 192 | let previousNode = nodes[index - 1] 193 | 194 | while (previousNode.children.length > 0) { 195 | previousNode = previousNode.children[previousNode.children.length - 1] 196 | } 197 | 198 | node.previous = previousNode.slug 199 | } 200 | 201 | if (isLast) { 202 | if (node.children.length === 0) { 203 | node.next = next 204 | } else { 205 | node.next = node.children[0].slug 206 | } 207 | 208 | connectNodes(node.children, node.slug, next) 209 | } else { 210 | const nextNode = nodes[index + 1] 211 | 212 | if (node.children.length === 0) { 213 | node.next = nextNode.slug 214 | } else { 215 | node.next = node.children[0].slug 216 | } 217 | 218 | connectNodes(node.children, node.slug, nextNode.slug) 219 | } 220 | }) 221 | } 222 | 223 | /** 224 | * @param {string} directory Directory to scan for pages 225 | * @param {any} variables Variable data for usage in frontmatter 226 | * @returns {TreeNode} 227 | */ 228 | function scan(directory, variables, fs = require('fs')) { 229 | const pagesPath = path.resolve(directory) 230 | 231 | let indexId = 0 232 | const topLevelPages = readTree(pagesPath, [], { 233 | id: indexId + 1, 234 | variables: variables, 235 | fs, 236 | }) 237 | 238 | connectNodes(topLevelPages, '') 239 | 240 | const file = 'index.mdx' 241 | const { data: frontmatter, content } = read(path.join(directory, file), fs) 242 | 243 | return { 244 | id: indexId, 245 | file, 246 | slug: '', 247 | title: frontmatter.title 248 | ? replaceTemplate(frontmatter.title, variables) 249 | : formatTitle('index'), 250 | subtitle: frontmatter.subtitle 251 | ? replaceTemplate(frontmatter.subtitle, variables) 252 | : undefined, 253 | children: topLevelPages, 254 | next: topLevelPages[0] ? topLevelPages[0].slug : undefined, 255 | headings: toc(content), 256 | ...(frontmatter.author && { 257 | author: { 258 | name: frontmatter.author.name, 259 | url: frontmatter.author.url, 260 | }, 261 | }), 262 | } 263 | } 264 | 265 | module.exports = scan 266 | -------------------------------------------------------------------------------- /next.js: -------------------------------------------------------------------------------- 1 | const EvalWebpackPlugin = require('eval-webpack-plugin') 2 | const scan = require('./index') 3 | const { createDocuments, buildIndex, exportIndex } = require('./search') 4 | 5 | module.exports = (pluginOptions = {}) => (nextConfig = {}) => { 6 | const { 7 | guidebookDirectory = './pages', 8 | guidebookModulePath = './guidebook.js', 9 | searchIndexPath = './searchIndex.js', 10 | searchIndexOptions = {}, 11 | variables, 12 | } = pluginOptions 13 | 14 | return Object.assign({}, nextConfig, { 15 | webpack(config, options) { 16 | config.plugins.push( 17 | new EvalWebpackPlugin(guidebookModulePath, () => 18 | scan(guidebookDirectory, variables) 19 | ) 20 | ) 21 | 22 | config.plugins.push( 23 | new EvalWebpackPlugin(searchIndexPath, () => { 24 | const root = scan(guidebookDirectory, variables) 25 | const documents = createDocuments(guidebookDirectory, root) 26 | const index = buildIndex(documents, searchIndexOptions) 27 | return { indexData: exportIndex(index), documents } 28 | }) 29 | ) 30 | 31 | if (typeof nextConfig.webpack === 'function') { 32 | return nextConfig.webpack(config, options) 33 | } 34 | 35 | return config 36 | }, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generate-guidebook", 3 | "version": "1.4.0", 4 | "main": "index.js", 5 | "types": "index.d.ts", 6 | "license": "MIT", 7 | "scripts": { 8 | "test": "jest" 9 | }, 10 | "dependencies": { 11 | "@mdx-js/mdx": "^1.6.16", 12 | "eval-webpack-plugin": "^1.1.0", 13 | "flexsearch": "^0.6.32", 14 | "gray-matter": "^4.0.2", 15 | "mdast-util-toc": "^5.1.0", 16 | "remark-mdx": "^1.6.22", 17 | "remark-parse": "^9.0.0", 18 | "remark-stringify": "^9.0.1", 19 | "unified": "^9.2.0", 20 | "unist-util-visit": "^2.0.3" 21 | }, 22 | "prettier": { 23 | "semi": false, 24 | "singleQuote": true, 25 | "trailingComma": "es5" 26 | }, 27 | "devDependencies": { 28 | "jest": "^26.0.1", 29 | "memfs": "^3.2.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /search.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const FlexSearch = require('flexsearch') 3 | const parser = require('./src/parser') 4 | const matter = require('gray-matter') 5 | const flatten = require('./src/flatten') 6 | 7 | /** 8 | * @typedef {import('flexsearch').Index} Index 9 | * @typedef {import('./index').TreeNode} TreeNode 10 | * @typedef {{ id: number, body: string }} Document 11 | */ 12 | 13 | /** 14 | * Create document. 15 | * 16 | * @param {number} id 17 | * @param {string} content 18 | * @returns {Document} 19 | */ 20 | function createDocument(id, content) { 21 | const root = parser.parse(content) 22 | 23 | return { 24 | id, 25 | body: flatten(root), 26 | } 27 | } 28 | 29 | /** 30 | * @param {string} directory 31 | * @param {TreeNode} root 32 | * @returns {Document[]} 33 | */ 34 | function createDocuments(directory, root, fs = require('fs')) { 35 | const resolvedDirectory = path.resolve(directory) 36 | 37 | function inner(currentDirectory, node, acc) { 38 | const route = path.join(currentDirectory, node.file) 39 | const filepath = path.join(resolvedDirectory, route) 40 | const content = matter(fs.readFileSync(filepath, 'utf8')).content 41 | 42 | const document = createDocument(node.id, content) 43 | document.title = node.title 44 | acc.push(document) 45 | 46 | const basename = path.basename(node.file, path.extname(node.file)) 47 | 48 | node.children.forEach((child) => { 49 | inner( 50 | node === root 51 | ? currentDirectory 52 | : path.join(currentDirectory, basename), 53 | child, 54 | acc 55 | ) 56 | }) 57 | 58 | return acc 59 | } 60 | 61 | return inner('', root, []) 62 | } 63 | 64 | /** 65 | * Build a search index from an array of documents. 66 | * 67 | * @param {Document[]} documents 68 | * @param {import('flexsearch').CreateOptions} options 69 | * @param {TreeNode} root 70 | */ 71 | function buildIndex(documents, options) { 72 | const index = new FlexSearch(options) 73 | 74 | documents.forEach((document) => { 75 | index.add(document.id, document.body) 76 | }) 77 | 78 | return index 79 | } 80 | 81 | /** 82 | * @param {Index} index 83 | * @returns {any} Serialized search index 84 | */ 85 | function exportIndex(index) { 86 | return index.export() 87 | } 88 | 89 | module.exports = { 90 | createDocuments, 91 | buildIndex, 92 | exportIndex, 93 | } 94 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/toc.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`parses headings 1`] = ` 4 | Array [ 5 | Object { 6 | "level": 1, 7 | "title": "H1 a", 8 | "url": "#h1-a", 9 | }, 10 | Object { 11 | "level": 1, 12 | "title": "H1 b", 13 | "url": "#h1-b", 14 | }, 15 | Object { 16 | "level": 2, 17 | "title": "H2 b 1", 18 | "url": "#h2-b-1", 19 | }, 20 | Object { 21 | "level": 3, 22 | "title": "H3 b 1 i", 23 | "url": "#h3-b-1-i", 24 | }, 25 | Object { 26 | "level": 3, 27 | "title": "H3 b 1 ii", 28 | "url": "#h3-b-1-ii", 29 | }, 30 | Object { 31 | "level": 2, 32 | "title": "H2 c 1", 33 | "url": "#h2-c-1", 34 | }, 35 | ] 36 | `; 37 | 38 | exports[`parses headings with JSX elements 1`] = ` 39 | Array [ 40 | Object { 41 | "level": 1, 42 | "title": "Foo Test", 43 | "url": "#foo-btestb", 44 | }, 45 | ] 46 | `; 47 | 48 | exports[`parses headings with formatting markdown 1`] = ` 49 | Array [ 50 | Object { 51 | "level": 1, 52 | "title": "Foo strong", 53 | "url": "#foo-strong", 54 | }, 55 | Object { 56 | "level": 1, 57 | "title": "Bar code", 58 | "url": "#bar-code", 59 | }, 60 | Object { 61 | "level": 1, 62 | "title": "Baz emphasis", 63 | "url": "#baz-emphasis", 64 | }, 65 | ] 66 | `; 67 | -------------------------------------------------------------------------------- /src/__tests__/toc.js: -------------------------------------------------------------------------------- 1 | const toc = require('../toc') 2 | 3 | const sample = ` 4 | # H1 a 5 | 6 | Other text 7 | 8 | # H1 b 9 | 10 | ## H2 b 1 11 | 12 | ### H3 b 1 i 13 | 14 | Some text 15 | 16 | ### H3 b 1 ii 17 | 18 | ## H2 c 1 19 | 20 | Some text 21 | ` 22 | 23 | test('parses headings', () => { 24 | const result = toc(sample) 25 | 26 | expect(result).toMatchSnapshot() 27 | }) 28 | 29 | const mdSample = ` 30 | # Foo **strong** 31 | 32 | # Bar \`code\` 33 | 34 | # Baz *emphasis* 35 | ` 36 | 37 | test('parses headings with formatting markdown', () => { 38 | const result = toc(mdSample) 39 | 40 | expect(result).toMatchSnapshot() 41 | }) 42 | 43 | const mdxSample = ` 44 | # Foo Test 45 | ` 46 | 47 | test('parses headings with JSX elements', () => { 48 | const result = toc(mdxSample) 49 | 50 | expect(result).toMatchSnapshot() 51 | }) 52 | -------------------------------------------------------------------------------- /src/flatten.js: -------------------------------------------------------------------------------- 1 | const visit = require('unist-util-visit') 2 | 3 | const textTypes = { 4 | text: true, 5 | inlineCode: true, 6 | } 7 | 8 | module.exports = function flatten(root) { 9 | let body = [] 10 | 11 | visit(root, (node) => { 12 | if (textTypes[node.type]) { 13 | body.push(node.value.trim()) 14 | } 15 | }) 16 | 17 | return body.join(' ') 18 | } 19 | -------------------------------------------------------------------------------- /src/parser.js: -------------------------------------------------------------------------------- 1 | const unified = require('unified') 2 | const parse = require('remark-parse') 3 | const mdx = require('remark-mdx') 4 | const stringify = require('remark-stringify') 5 | 6 | const parser = unified().use(parse).use(stringify).use(mdx) 7 | 8 | module.exports = parser 9 | -------------------------------------------------------------------------------- /src/toc.js: -------------------------------------------------------------------------------- 1 | const toc = require('mdast-util-toc') 2 | const flatten = require('./flatten') 3 | const parser = require('./parser') 4 | 5 | function compressList(ast) { 6 | return inner(ast, 1).flat(Infinity) 7 | 8 | function inner(node, depth) { 9 | if (!node) return [] 10 | 11 | return node.children.map((listItem) => { 12 | return listItem.children.map((child) => { 13 | switch (child.type) { 14 | case 'list': 15 | return inner(child, depth + 1) 16 | case 'paragraph': 17 | const { url } = child.children[0] 18 | 19 | return { 20 | level: depth, 21 | title: flatten(child), 22 | url, 23 | } 24 | default: 25 | return child 26 | } 27 | }) 28 | }) 29 | } 30 | } 31 | 32 | /** 33 | * @param {string} content 34 | */ 35 | module.exports = function parseHeadings(content) { 36 | const root = parser.parse(content) 37 | 38 | const result = toc(root, {}) 39 | 40 | return compressList(result.map) 41 | } 42 | --------------------------------------------------------------------------------