├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── manifest.yml ├── netlify.toml └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://buymeacoffee.com/daviddarnes#support 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Dependency directories 9 | node_modules/ 10 | jspm_packages/ 11 | 12 | package-lock.json 13 | 14 | # Output of 'npm pack' 15 | *.tgz -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 David Darnes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Netlify Ghost Markdown Build Plugin 2 | 3 | ![npm version badge](https://img.shields.io/npm/v/netlify-plugin-ghost-markdown) 4 | 5 | This plugin generates posts, pages, tag pages and author pages from a [Ghost](https://ghost.org) publication as markdown files, using the [Ghost Content API](https://ghost.org/docs/api/v3/content/). In addition it will copy images from the Ghost publication into a local assets directory. Pages, posts, tag pages, author pages and images will be generated on the `onPreBuild` event in the Netlify deployment cycle, this is to ensure the files are present before an actual build occurs. 6 | 7 | [](https://buymeacoffee.com/daviddarnes#support) 8 | 9 | ## Prerequisites 10 | 11 | Before you can use this package you'll need a Netlify project that can consume markdown files and images. This package was built with [Jekyll](https://jekyllrb.com) in mind, however in theory it should work with any static site generator :sparkles:. Additionally you'll need the [Netlify CLI tool](https://github.com/netlify/cli#netlify-cli) installed and [Build Plugins Beta enabled](https://docs.netlify.com/configure-builds/plugins). 12 | 13 | ## Installation 14 | 15 | To install, add the following lines to your `netlify.toml` file: 16 | 17 | ```toml 18 | [[plugins]] 19 | package = "netlify-plugin-ghost-markdown" 20 | 21 | [plugins.inputs] 22 | ghostURL = "https://YOURGHOST.URL" 23 | ghostKey = "YOURGHOSTKEY" 24 | ``` 25 | 26 | Note: The `[[plugins]]` line is required for each plugin, even if you have other plugins in your `netlify.toml` file already. 27 | 28 | You'll need to get a Ghost Content API URL and key to authenticate with your Ghost publication. Please see [the Ghost documentation](https://ghost.org/docs/api/v3/javascript/content/#authentication) for more info. 29 | 30 | _Psst, test credentials can be "borrowed" from here: https://ghost.org/docs/api/v3/javascript/content/#working-example_ 31 | 32 | ## Configuration 33 | 34 | ```toml 35 | [[plugins]] 36 | package = "netlify-plugin-ghost-markdown" 37 | 38 | [plugins.inputs] 39 | # Required: Your Ghost domain, must not end in a trailing slash 40 | ghostURL = "https://YOURGHOST.URL" 41 | 42 | # Required: Content API key from the Integrations screen in Ghost Admin 43 | ghostKey = "YOURGHOSTKEY" 44 | 45 | # Optional: Directory containing image assets (assets/images by default) 46 | assetsDir = "./assets/images/" 47 | 48 | # Optional: Directory containing pages (site root by default) 49 | pagesDir = "./" 50 | 51 | # Optional: Directory containing posts (_posts/ directory by default) 52 | postsDir = "./_posts/" 53 | 54 | # Optional: Output tag pages (false by default) 55 | tagPages = false 56 | 57 | # Optional: Output author pages (false by default) 58 | authorPages = false 59 | 60 | # Optional: Directory containing tags (tag/ directory by default) 61 | tagsDir = "tag/" 62 | 63 | # Optional: Directory containing authors (author/ directory by default) 64 | authorsDir = "author/" 65 | 66 | # Optional: Layout value for pages (page by default) 67 | pageLayout = "page" 68 | 69 | # Optional: Layout value for posts (post by default) 70 | postsLayout = "post" 71 | 72 | # Optional: Layout value for pages (page by default) 73 | tagsLayout = "tag" 74 | 75 | # Optional: Layout value for posts (post by default) 76 | authorsLayout = "author" 77 | 78 | # Optional: Date prefix on post file names (true by default) 79 | postDatePrefix = true 80 | 81 | # Optional: File path and name for a timestamp caching file (_data/ghostMarkdownCache.json by default) 82 | cacheFile = "./_data/ghostMarkdownCache.json" 83 | ``` 84 | 85 | Currently posts follow the [Jekyll markdown file name format](https://jekyllrb.com/docs/posts/#creating-posts). Set the `postDatePrefix` to false to use the post slug as the file name 86 | 87 | ## Development 88 | 89 | _Testing inside the project is proving difficult at the minute. [Currently requesting support on a practical method here](https://community.netlify.com/t/creating-demos-for-build-plugins/12774/8)_ 90 | 91 | _Currently you need clone this project and copy the plugin into an example site, copy the `package.json` into the root of the project, and then run it like a [custom build plugin](https://docs.netlify.com/configure-builds/build-plugins/create-plugins/). You can [download the demo site here](https://github.com/daviddarnes/netlify-plugin-dropinblog-markdown-demo) if you need it as a quick starting point. Don't forget to install the dependencies and [run it using Netlify CLI](https://docs.netlify.com/cli/get-started/#run-builds-locally)!_ 92 | 93 | # License 94 | 95 | Released under the [MIT license](LICENSE). 96 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs-extra"); 2 | const path = require("path"); 3 | const fetch = require("node-fetch"); 4 | const url = require("url"); 5 | const { cyan, green, yellow } = require("chalk"); 6 | const ghostContentAPI = require("@tryghost/content-api"); 7 | 8 | const log = ({ color, label, value = false }) => { 9 | // log({ 10 | // color = chalk color 11 | // label = string, text label 12 | // value = var, value being read out 13 | // }); 14 | console.log(`${color(label)}${value ? color(`: ${color.bold(value)}`) : ""}`); 15 | }; 16 | 17 | const getContent = async ({ contentType, failPlugin }) => { 18 | // getContent({ 19 | // contentType = api.posts || api.pages 20 | // failPlugin = failPlugin 21 | // }); 22 | 23 | try { 24 | // Retrieve the content using the set API endpoint 25 | const content = await contentType.browse({ 26 | include: "tags,authors", 27 | limit: "all" 28 | }); 29 | 30 | // Return content 31 | return content; 32 | } catch (error) { 33 | failPlugin("Ghost API content error", { error }); 34 | } 35 | }; 36 | 37 | const downloadImage = async ({ imagePath, outputPath, failPlugin }) => { 38 | // downloadImage({ 39 | // imagePath = string, image path 40 | // outputPath = string, desired relative image path 41 | // failPlugin = failPlugin 42 | // }); 43 | 44 | try { 45 | // Grab file data from remote path 46 | const response = await fetch(imagePath); 47 | const fileData = await response.buffer(); 48 | 49 | // Write the file and cache it 50 | await fs.outputFile(outputPath, fileData); 51 | } catch (error) { 52 | failPlugin("Image download error", { error }); 53 | } 54 | }; 55 | 56 | const getRelativeImagePath = ({ imagePath, contentPath }) => { 57 | // getRelativeImagePath({ 58 | // imagePath = string, the path of the image 59 | // contentPath = string, the path of the post or page 60 | // }); 61 | 62 | // Split both paths into arrays, at their directory points 63 | const explodedImagePath = imagePath.split("/"); 64 | const explodedContentPath = contentPath.split("/"); 65 | 66 | // Find the point at which the image path diverges from the content path 67 | const difference = explodedImagePath.findIndex((slice, index) => { 68 | return explodedContentPath[index] != slice; 69 | }); 70 | 71 | // Reconstruct the image path from the point it diverges to it's end 72 | const relativePath = `/${explodedImagePath.slice(difference).join("/")}`; 73 | 74 | // Return the new image path 75 | return relativePath; 76 | }; 77 | 78 | const dedent = ({ string }) => { 79 | // dedent({ 80 | // string = string, the template content 81 | // }); 82 | 83 | // Take any string and remove indentation 84 | string = string.replace(/^\n/, ""); 85 | const match = string.match(/^\s+/); 86 | const dedentedString = match 87 | ? string.replace(new RegExp("^" + match[0], "gm"), "") 88 | : string; 89 | 90 | return dedentedString; 91 | }; 92 | 93 | const formatImagePaths = ({ string, imagesPath, assetsDir }) => { 94 | // formatImagePaths({ 95 | // string = string, the template content 96 | // imagesPath = string, original Ghost image path 97 | // assetsDir = string, the new path for the image 98 | // }); 99 | 100 | const imagePathRegex = new RegExp(imagesPath, "g"); 101 | 102 | if (string && string.match(imagePathRegex)) { 103 | // Take a string and replace the Ghost image path with the new images path 104 | return string.replace(imagePathRegex, assetsDir); 105 | } 106 | return string; 107 | }; 108 | 109 | const createMarkdownContent = ({ content, imagesPath, assetsDir, layout }) => { 110 | // createMarkdownContent({ 111 | // content = object, the content item 112 | // imagesPath = string, the base path for Ghost images 113 | // assetsPath = string, the new path for images 114 | // layout = string, the layout name 115 | // }); 116 | 117 | // Format tags into a comma separated string 118 | const formatTags = (tags) => { 119 | if (tags) { 120 | return `[${tags.map((tag) => tag.slug).join(", ")}]`; 121 | } 122 | return ""; 123 | }; 124 | 125 | // Create the markdown template 126 | const template = ` 127 | --- 128 | date: ${content.published_at.slice(0, 10)} 129 | title: "${content.title}" 130 | layout: ${layout} 131 | excerpt: "${content.custom_excerpt ? content.custom_excerpt : ""}" 132 | image: "${ 133 | content.feature_image 134 | ? formatImagePaths({ 135 | string: content.feature_image, 136 | imagesPath, 137 | assetsDir 138 | }) 139 | : "" 140 | }" 141 | tags: ${formatTags(content.tags)} 142 | --- 143 | ${ 144 | content.html 145 | ? formatImagePaths({ 146 | string: content.html, 147 | imagesPath, 148 | assetsDir 149 | }) 150 | : "" 151 | } 152 | `; 153 | 154 | // Return the template without the indentation 155 | return dedent({ string: template }); 156 | }; 157 | 158 | const createTagMarkdown = ({ content, imagesPath, assetsDir, layout }) => { 159 | // createTagMarkdown({ 160 | // content = onject, the full content of the item 161 | // imagesPath = string, the base path for Ghost images 162 | // assetsPath = string, the new path for images 163 | // layout = string, the layout name 164 | //}); 165 | 166 | // Create the frontmatter template 167 | const template = ` 168 | --- 169 | title: "${content.name ? content.name : content.slug}" 170 | layout: ${layout} 171 | excerpt: "${content.description ? content.description : ""}" 172 | image: "${ 173 | content.feature_image 174 | ? formatImagePaths({ 175 | string: content.feature_image, 176 | imagesPath, 177 | assetsDir 178 | }) 179 | : "" 180 | }" 181 | --- 182 | ${content.html} 183 | `; 184 | 185 | // Return the template without the indentation 186 | return dedent({ string: template }); 187 | }; 188 | 189 | const createAuthorMarkdown = ({ content, imagesPath, assetsDir, layout }) => { 190 | // createAuthorMarkdown({ 191 | // content = onject, the full content of the item 192 | // imagesPath = string, the base path for Ghost images 193 | // assetsPath = string, the new path for images 194 | // layout = string, the layout name 195 | //}); 196 | 197 | // Create the frontmatter template 198 | const template = ` 199 | --- 200 | title: "${content.name ? content.name : content.slug}" 201 | layout: ${layout} 202 | excerpt: "${content.bio ? content.bio : ""}" 203 | image: "${ 204 | content.cover_image 205 | ? formatImagePaths({ 206 | string: content.cover_image, 207 | imagesPath, 208 | assetsDir 209 | }) 210 | : "" 211 | }" 212 | --- 213 | ${content.html} 214 | `; 215 | 216 | // Return the template without the indentation 217 | return dedent({ string: template }); 218 | }; 219 | 220 | const createTaxonomyContent = ({ taxonomyItem, items, postDatePrefix }) => { 221 | const descriptionLine = taxonomyItem.description 222 | ? `

${taxonomyItem.description}

` 223 | : ""; 224 | 225 | const itemsList = items.length 226 | ? ` 227 |
    228 | ${items 229 | .map((item) => { 230 | // Format post links to match date prefixing, if set 231 | const link = 232 | postDatePrefix && !item.page 233 | ? `${item.published_at.slice(0, 10).replace(/-/g, "/")}/${ 234 | item.slug 235 | }` 236 | : item.slug; 237 | ``; 238 | 239 | return ` 240 |
  1. 241 | ${item.title} 242 | ${item.excerpt ? `

    ${item.excerpt}

    ` : ""} 243 |
  2. 244 | `; 245 | }) 246 | .join("")} 247 |
248 | ` 249 | : ""; 250 | 251 | return dedent({ string: descriptionLine + itemsList }); 252 | }; 253 | 254 | const writeFile = async ({ fullFilePath, content, failPlugin }) => { 255 | // writeFile({ 256 | // fullFilePath = string, the full file path and name with extension 257 | // content = contents of the file 258 | // failPlugin = failPlugin 259 | //}); 260 | 261 | try { 262 | // Output file using path and name with it's content within 263 | await fs.outputFile(fullFilePath, content); 264 | } catch (error) { 265 | failPlugin(`Error writing ${fullFilePath}`, { error }); 266 | } 267 | }; 268 | 269 | const getCacheTimestamp = async ({ cache, fullFilePath, failPlugin }) => { 270 | // getCacheTimestamp({ 271 | // cache = cache 272 | // fullFilePath = string, the local file path and name 273 | // failPlugin: failPlugin 274 | // }); 275 | 276 | if (await cache.has(fullFilePath)) { 277 | await cache.restore(fullFilePath); 278 | const cacheDate = await readFile({ 279 | file: fullFilePath, 280 | failPlugin: failPlugin 281 | }); 282 | 283 | // Log cache timestamp in console 284 | log({ 285 | color: yellow, 286 | label: "Restoring markdown cache from", 287 | value: cacheDate 288 | }); 289 | return new Date(cacheDate); 290 | } else { 291 | // Log no cache file found 292 | log({ 293 | color: yellow, 294 | label: "No cache file found" 295 | }); 296 | return 0; 297 | } 298 | }; 299 | 300 | const writeCacheTimestamp = async ({ cache, fullFilePath, failPlugin }) => { 301 | // writeCacheTimestamp({ 302 | // cache = cache 303 | // fullFilePath = string, the local file path and name 304 | // failPlugin = failPlugin 305 | // }); 306 | 307 | // Get the timestamp of right now 308 | const now = new Date(); 309 | const nowISO = now.toISOString(); 310 | 311 | // Write the time into a cache file 312 | await writeFile({ 313 | fullFilePath: fullFilePath, 314 | content: `"${nowISO}"`, 315 | failPlugin: failPlugin 316 | }); 317 | 318 | await cache.save(fullFilePath); 319 | 320 | // Log cache timestamp creation time 321 | log({ 322 | color: yellow, 323 | label: "Caching markdown at", 324 | value: nowISO 325 | }); 326 | }; 327 | 328 | const readFile = async ({ file, failPlugin }) => { 329 | // readFile({ 330 | // file = string, the local file path and name 331 | // failPlugin = failPlugin 332 | // }); 333 | 334 | // Replace root path syntax with environment 335 | const fullFilePath = file.replace("./", `${process.cwd()}/`); 336 | const fileContent = require(fullFilePath); 337 | 338 | // Return file content 339 | return fileContent; 340 | }; 341 | 342 | const getAllImages = ({ contentItems, imagesPath }) => { 343 | // getAllImages({ 344 | // contentItems = array, post, page, tag, author objects 345 | // imagesPath = string, the base path for Ghost images 346 | // }); 347 | 348 | const htmlWithImages = contentItems 349 | .filter((item) => { 350 | return item.html && item.html.includes(imagesPath); 351 | }) 352 | .map((filteredItem) => filteredItem.html); 353 | 354 | const htmlImages = htmlWithImages 355 | .map((html) => { 356 | return html.split(/[\ "]/).filter((slice) => slice.includes(imagesPath)); 357 | }) 358 | .flat(); 359 | 360 | const featureImages = contentItems 361 | .filter((item) => { 362 | return item.feature_image && item.feature_image.includes(imagesPath); 363 | }) 364 | .map((item) => item.feature_image); 365 | 366 | const coverImages = contentItems 367 | .filter((item) => { 368 | return item.cover_image && item.cover_image.includes(imagesPath); 369 | }) 370 | .map((item) => item.cover_image); 371 | 372 | const allImages = [ 373 | ...new Set([...htmlImages, ...featureImages, ...coverImages]) 374 | ]; 375 | 376 | return allImages; 377 | }; 378 | 379 | // Begin plugin export 380 | module.exports = { 381 | onPreBuild: async ({ 382 | inputs: { 383 | ghostURL, 384 | ghostKey, 385 | assetsDir = "./assets/images/", 386 | pagesDir = "./", 387 | postsDir = "./_posts/", 388 | tagPages = false, 389 | authorPages = false, 390 | tagsDir = "./tag/", 391 | authorsDir = "./author/", 392 | pagesLayout = "page", 393 | postsLayout = "post", 394 | tagsLayout = "tag", 395 | authorsLayout = "author", 396 | postDatePrefix = true, 397 | cacheFile = "./_data/ghostMarkdownCache.json" 398 | }, 399 | utils: { 400 | build: { failPlugin }, 401 | cache 402 | } 403 | }) => { 404 | // Ghost images path 405 | const ghostImagePath = ghostURL + "/content/images/"; 406 | 407 | // Initialise Ghost Content API 408 | const api = new ghostContentAPI({ 409 | url: ghostURL, 410 | key: ghostKey, 411 | version: "v2" 412 | }); 413 | 414 | const [posts, pages, cacheDate, tags, authors] = await Promise.all([ 415 | getContent({ 416 | contentType: api.posts, 417 | failPlugin: failPlugin 418 | }), 419 | getContent({ 420 | contentType: api.pages, 421 | failPlugin: failPlugin 422 | }), 423 | getCacheTimestamp({ 424 | cache: cache, 425 | fullFilePath: cacheFile, 426 | failPlugin: failPlugin 427 | }), 428 | tagPages 429 | ? getContent({ 430 | contentType: api.tags, 431 | failPlugin: failPlugin 432 | }) 433 | : [], 434 | authorPages 435 | ? getContent({ 436 | contentType: api.authors, 437 | failPlugin: failPlugin 438 | }) 439 | : [] 440 | ]); 441 | 442 | await Promise.all([ 443 | // Get all images from out of posts and pages 444 | ...getAllImages({ 445 | contentItems: [ 446 | ...posts, 447 | ...pages, 448 | ...(tagPages ? tags : []), 449 | ...(authorPages ? authors : []) 450 | ], 451 | imagesPath: ghostImagePath 452 | }).map(async (image) => { 453 | // Create destination for each image 454 | const dest = image.replace(ghostImagePath, assetsDir); 455 | 456 | // If the image isn't in cache download it 457 | if (!(await cache.has(dest))) { 458 | await downloadImage({ 459 | imagePath: image, 460 | outputPath: dest, 461 | failPlugin: failPlugin 462 | }); 463 | 464 | // Cache the image 465 | await cache.save(dest); 466 | 467 | log({ 468 | color: green, 469 | label: "Downloaded and cached", 470 | value: dest 471 | }); 472 | } else { 473 | // Restore the image if it's already in the cache 474 | await cache.restore(dest); 475 | 476 | log({ 477 | color: cyan, 478 | label: "Restored from cache", 479 | value: dest 480 | }); 481 | } 482 | }), 483 | ...posts.map(async (post) => { 484 | // Set the file name using the post slug 485 | let fileName = `${post.slug}.md`; 486 | 487 | // If postDatePrefix is true prefix file with post date 488 | if (postDatePrefix) { 489 | fileName = `${post.published_at.slice(0, 10)}-${post.slug}.md`; 490 | } 491 | 492 | // The full file path and name 493 | const fullFilePath = postsDir + fileName; 494 | 495 | // Get the post updated date and last cached date 496 | const postUpdatedAt = new Date(post.updated_at); 497 | 498 | if ((await cache.has(fullFilePath)) && cacheDate > postUpdatedAt) { 499 | // Restore markdown from cache 500 | await cache.restore(fullFilePath); 501 | 502 | log({ 503 | color: cyan, 504 | label: "Restored from cache", 505 | value: fullFilePath 506 | }); 507 | } else { 508 | // Generate markdown file 509 | await writeFile({ 510 | fullFilePath: fullFilePath, 511 | content: createMarkdownContent({ 512 | content: post, 513 | imagesPath: ghostImagePath, 514 | assetsDir: getRelativeImagePath({ 515 | imagePath: assetsDir, 516 | contentPath: postsDir 517 | }), 518 | layout: postsLayout 519 | }) 520 | }); 521 | // Cache the markdown file 522 | await cache.save(fullFilePath); 523 | 524 | log({ 525 | color: green, 526 | label: "Generated and cached", 527 | value: fullFilePath 528 | }); 529 | } 530 | }), 531 | ...pages.map(async (page) => { 532 | // Set the file name using the page slug 533 | let fileName = `${page.slug}.md`; 534 | 535 | // The full file path and name 536 | const fullFilePath = pagesDir + fileName; 537 | 538 | // Get the page updated date and last cached date 539 | const pageUpdatedAt = new Date(page.updated_at); 540 | 541 | if ((await cache.has(fullFilePath)) && cacheDate > pageUpdatedAt) { 542 | // Restore markdown from cache 543 | await cache.restore(fullFilePath); 544 | 545 | log({ 546 | color: cyan, 547 | label: "Restored from cache", 548 | value: fullFilePath 549 | }); 550 | } else { 551 | // Generate markdown file 552 | await writeFile({ 553 | fullFilePath: fullFilePath, 554 | content: createMarkdownContent({ 555 | content: page, 556 | imagesPath: ghostImagePath, 557 | assetsDir: getRelativeImagePath({ 558 | imagePath: assetsDir, 559 | contentPath: pagesDir 560 | }), 561 | layout: pagesLayout 562 | }) 563 | }); 564 | // Cache the markdown file 565 | await cache.save(fullFilePath); 566 | 567 | log({ 568 | color: green, 569 | label: "Generated and cached", 570 | value: fullFilePath 571 | }); 572 | } 573 | }), 574 | ...(tagPages 575 | ? tags.map(async (tag) => { 576 | // Filter posts and pages to only tagged items 577 | const taggedItems = [...pages, ...posts].filter((items) => { 578 | return items.tags.some((postTag) => postTag.slug === tag.slug); 579 | }); 580 | 581 | // Add content to the author page 582 | tag.html = createTaxonomyContent({ 583 | taxonomyItem: tag, 584 | items: taggedItems, 585 | postDatePrefix 586 | }); 587 | 588 | // Set the file name using the page slug 589 | let fileName = `${tag.slug}.md`; 590 | 591 | // The full file path and name 592 | const fullFilePath = tagsDir + fileName; 593 | 594 | // Generate markdown file 595 | await writeFile({ 596 | fullFilePath: fullFilePath, 597 | content: createTagMarkdown({ 598 | content: tag, 599 | imagesPath: ghostImagePath, 600 | assetsDir: getRelativeImagePath({ 601 | imagePath: assetsDir, 602 | contentPath: tagsDir 603 | }), 604 | layout: tagsLayout 605 | }) 606 | }); 607 | }) 608 | : []), 609 | ...(authorPages 610 | ? authors.map(async (author) => { 611 | // Filter posts and pages to only tagged items 612 | const authoredItems = [...pages, ...posts].filter((items) => { 613 | return items.authors.some( 614 | (postAuthor) => postAuthor.slug === author.slug 615 | ); 616 | }); 617 | 618 | // Add content to the author page 619 | author.html = createTaxonomyContent({ 620 | taxonomyItem: author, 621 | items: authoredItems, 622 | postDatePrefix 623 | }); 624 | 625 | // Set the file name using the page slug 626 | let fileName = `${author.slug}.md`; 627 | 628 | // The full file path and name 629 | const fullFilePath = authorsDir + fileName; 630 | 631 | // Generate markdown file 632 | await writeFile({ 633 | fullFilePath: fullFilePath, 634 | content: createAuthorMarkdown({ 635 | content: author, 636 | imagesPath: ghostImagePath, 637 | assetsDir: getRelativeImagePath({ 638 | imagePath: assetsDir, 639 | contentPath: authorsDir 640 | }), 641 | layout: authorsLayout 642 | }) 643 | }); 644 | }) 645 | : []) 646 | ]).then(async (response) => { 647 | // Write a new cache file 648 | await writeCacheTimestamp({ 649 | cache: cache, 650 | fullFilePath: cacheFile, 651 | failPlugin: failPlugin 652 | }); 653 | }); 654 | } 655 | }; 656 | -------------------------------------------------------------------------------- /manifest.yml: -------------------------------------------------------------------------------- 1 | name: netlify-plugin-ghost-markdown 2 | inputs: 3 | - name: ghostURL 4 | description: Your Ghost domain, must not end in a trailing slash 5 | required: true 6 | - name: ghostKey 7 | description: Content API key from the Integrations screen in Ghost Admin 8 | required: true 9 | - name: assetsDir 10 | description: Directory containing image assets (assets/images by default) 11 | - name: pagesDir 12 | description: Directory containing pages (site root by default) 13 | - name: postsDir 14 | description: Directory containing posts (_posts/ directory by default) 15 | - name: tagPages 16 | description: Optionally output tag pages (turned off by default) 17 | - name: authorPages 18 | description: Optionally output author pages (turned off by default) 19 | - name: tagsDir 20 | description: Directory containing tags (tag/ directory by default) 21 | - name: authorsDir 22 | description: Directory containing authors (author/ directory by default) 23 | - name: pagesLayout 24 | description: Layout value for pages (page by default) 25 | - name: postsLayout 26 | description: Layout value for posts (post by default) 27 | - name: tagsLayout 28 | description: Layout value for tags (tag by default) 29 | - name: authorsLayout 30 | description: Layout value for authors (author by default) 31 | - name: postDatePrefix 32 | description: Date prefix on post file names (true by default) 33 | - name: cacheFile 34 | description: Set the filename and path for the cache file (_data/ghostMarkdownCache.json by default) 35 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run clonePlugin && cd test && bundle install && bundle exec jekyll build" 3 | publish = "test/_site" 4 | 5 | [build.environment] 6 | JEKYLL_ENV = "production" 7 | 8 | [[plugins]] 9 | package = "./test/plugins/index.js" 10 | 11 | [plugins.inputs] 12 | ghostURL = "https://demo.ghost.io" 13 | ghostKey = "22444f78447824223cefc48062" 14 | assetsDir = "./test/assets/images/" 15 | pagesDir = "./test/" 16 | postsDir = "./test/_posts/" 17 | cacheFile = "./test/_data/ghostMarkdownCache.json" 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netlify-plugin-ghost-markdown", 3 | "version": "3.1.0", 4 | "description": "Generates posts, pages, tag pages and author pages from a Ghost publication as markdown files, using the Ghost Content API.", 5 | "repository": "git://github.com/daviddarnes/netlify-plugin-ghost-markdown.git", 6 | "bugs": { 7 | "url": "https://github.com/daviddarnes/netlify-plugin-ghost-markdown/issues" 8 | }, 9 | "main": "index.js", 10 | "maintainers": [ 11 | { 12 | "name": "David Darnes", 13 | "email": "me@daviddarnes.com" 14 | } 15 | ], 16 | "scripts": { 17 | "publish": "git push origin && git push origin --tags", 18 | "release:patch": "npm version patch && npm publish", 19 | "release:minor": "npm version minor && npm publish", 20 | "release:major": "npm version major && npm publish" 21 | }, 22 | "license": "MIT", 23 | "keywords": [ 24 | "netlify", 25 | "netlify-plugin", 26 | "ghost", 27 | "markdown" 28 | ], 29 | "dependencies": { 30 | "@tryghost/content-api": "^1.4.1", 31 | "chalk": "^4.0.0", 32 | "fs-extra": "^8.1.0", 33 | "node-fetch": "^2.6.0" 34 | }, 35 | "devDependencies": { 36 | "@netlify/build": "^27.15.6", 37 | "copyfiles": "^2.2.0", 38 | "netlify-cli": "^11.5.0" 39 | } 40 | } 41 | --------------------------------------------------------------------------------