├── .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 | 
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 |${item.excerpt}
` : ""} 243 |