-
Notifications
You must be signed in to change notification settings - Fork 5
/
gatsbyNode.ts
407 lines (358 loc) · 12.4 KB
/
gatsbyNode.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
/* eslint-disable no-console, @scottnonnenberg/thehelp/absolute-or-current-dir */
import { writeFileSync } from 'fs';
import { join, relative, resolve } from 'path';
import { Feed } from 'feed';
import type {
BuildArgs,
CreateNodeArgs,
CreatePagesArgs,
CreateResolversArgs,
Node,
} from 'gatsby';
// Note: we need to use relative paths here because app-module-path causes WebPack 5 to throw
// PackFileCacheStrategy/FileSystemInfo warnings. Even though the overall build works.
import type { PostType } from 'src/types/Post';
import type { AllDataQueryType, AllPostsQueryType } from 'src/types/queries';
import { appendToLastTextBlock } from './src/util/appendToLastTextBlock';
import { fixLocalLinks } from './src/util/fixLocalLinks';
import { getPreFoldContent } from './src/util/getPreFoldContent';
import { getTagCounts } from './src/util/getTagCounts';
import { prune } from './src/util/prune';
import { removeTags } from './src/util/removeTags';
const POST_COUNT_FOR_FEEDS = 20;
const RECENT_COUNT_FOR_SYNDICATION = 10;
const TAG_POSTS_WITH_HTML_PREVIEW = 5;
type RawAllPostsQueryType = {
errors?: Array<Error>;
data?: AllPostsQueryType;
};
type RawAllDataType = {
errors?: Array<Error>;
data?: AllDataQueryType;
};
// We would like to use the official GatsbyNode type here, but the onCreateNode typings
// are forcing the node parameter type to {}, causing problems for us when we give it
// a real type.
// https://github.com/gatsbyjs/gatsby/blob/54e3d7ae24924215ae9e0976b89e185159d9e38f/packages/gatsby/index.d.ts#L284-L288
type NodeType = Node & {
frontmatter?: {
path: string;
};
};
function getHTMLPreview(html: string, slug: string): string {
const preFold = getPreFoldContent(html);
if (!preFold) {
throw new Error('getHTMLPreview: Missing pre-fold content!');
}
const textLink = ` <a href="${slug}">Read more »</a>`;
return appendToLastTextBlock(preFold, textLink);
}
const MAX_TEXT_PREVIEW = 200;
function getTextPreview(html: string) {
const preFold = getPreFoldContent(html);
if (!preFold) {
throw new Error('getTextPreview: Missing pre-fold content!');
}
const noTags = removeTags(preFold);
if (!noTags) {
throw new Error(`getTextPreview: No tags returned for html: ${preFold}`);
}
return prune(noTags, MAX_TEXT_PREVIEW);
}
const gatsbyNode = {
createPages: async ({ graphql, actions }: CreatePagesArgs): Promise<void> => {
const { createPage } = actions;
const blogPostPage = resolve('./src/dynamic-pages/post.tsx');
const tagPage = resolve('./src/dynamic-pages/tag.tsx');
const result: RawAllPostsQueryType = await graphql(
`
{
allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
edges {
node {
htmlPreview
textPreview
fields {
slug
}
frontmatter {
date
tags
title
path
}
}
}
}
}
`
);
if (result.errors?.length) {
throw result.errors[0] ?? new Error('Something went wrong!');
}
if (!result.data) {
console.error('Query results malformed', result);
throw new Error('Query returned no data!');
}
// Create a page for each blog post
const posts = result.data.allMarkdownRemark.edges.map(item => item.node);
posts.forEach((post, index) => {
const next = index === 0 ? null : posts[index - 1];
const previous = index === posts.length - 1 ? null : posts[index + 1];
const path = post.fields?.slug;
if (!path) {
throw new Error(`Page had missing slug: ${JSON.stringify(post)}`);
}
createPage({
path,
component: blogPostPage,
context: {
slug: path,
previous: previous && {
...previous,
htmlPreview: undefined,
},
next: next && {
...next,
htmlPreview: undefined,
},
},
});
});
// Create a page for each tag
const tagCounts = getTagCounts(posts);
tagCounts.forEach(({ tag }) => {
if (!tag) {
return;
}
const postsWithTag = posts.filter(post => post.frontmatter?.tags?.includes(tag));
const withText: Array<PostType> = [];
const justLink: Array<PostType> = [];
// By removing some of this data, we can reduce the size of the page-data.json for
// this page.
postsWithTag.forEach((post, index) => {
if (index <= TAG_POSTS_WITH_HTML_PREVIEW) {
withText.push({
...post,
htmlPreview: undefined,
});
} else {
justLink.push({
...post,
htmlPreview: undefined,
textPreview: undefined,
});
}
});
createPage({
path: `/tags/${tag}`,
component: tagPage,
context: {
tag,
withText,
justLink,
},
});
});
},
// This is the easy way to add fields to a given GrapnQL node.
// Note: values generated here are persisted in Gatsby's build cache, so it should be
// reserved for values that don't change very often. If you make a change here while
// running 'yarn develop', you'll need to both shut down and `yarn clean` before it
// will show up.
onCreateNode: ({ node, actions }: CreateNodeArgs<NodeType>): void => {
const { createNodeField } = actions;
if (node.internal.type === 'MarkdownRemark') {
const slug: string | undefined = node.frontmatter?.path;
if (!slug) {
throw new Error(`Post was missing path: ${JSON.stringify(node)}`);
}
createNodeField({
name: 'slug',
node,
value: slug,
});
const absolutePath = node['fileAbsolutePath'];
if (typeof absolutePath !== 'string') {
throw new Error(`Post was missing fileAbsolutePath: ${JSON.stringify(node)}`);
}
const relativePath = relative(__dirname, absolutePath);
createNodeField({
name: 'relativePath',
node,
value: relativePath,
});
}
},
// This server-side calculation of htmlPreview/textPreview ensure that Gatsby's
// generated page-data.json files don't balloon out of control. We get complex here,
// adding custom fields to the GraphQL, because gatsby-transformer-remark lazily
// generates HTML from markdown. We only get html when we call that plugin's resolver
// manually!
// Thanks, @zaparo! https://github.com/gatsbyjs/gatsby/issues/17045#issuecomment-529161439
/* eslint-disable max-params, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
createResolvers: ({ createResolvers }: CreateResolversArgs): any => {
const resolvers = {
MarkdownRemark: {
htmlPreview: {
type: 'String',
resolve: async (source: PostType, args: any, context: any, info: any) => {
const htmlField = info.schema.getType('MarkdownRemark').getFields().html;
const html = await htmlField.resolve(source, args, context, info);
const slug = source.frontmatter?.path;
if (!slug) {
throw new Error(`source was missing path: ${JSON.stringify(source)}`);
}
return getHTMLPreview(html, slug);
},
},
textPreview: {
type: 'String',
resolve: async (source: PostType, args: any, context: any, info: any) => {
const htmlField = info.schema.getType('MarkdownRemark').getFields().html;
const html = await htmlField.resolve(source, args, context, info);
return getTextPreview(html);
},
},
},
};
createResolvers(resolvers);
},
/* eslint-enable max-params, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
// Build key assets that live next to built files in public/
onPostBuild: async ({ graphql }: BuildArgs): Promise<void> => {
const result: RawAllDataType = await graphql(
`
{
site {
siteMetadata {
blogTitle
favicon
domain
author {
name
email
twitter
url
image
blurb
}
}
}
allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
edges {
node {
html
textPreview
htmlPreview
fields {
slug
}
frontmatter {
date
tags
title
path
}
}
}
}
}
`
);
if (result.errors?.length) {
throw result.errors[0] ?? new Error('Something went wrong!');
}
if (!result.data) {
console.error('Query results malformed', result);
throw new Error('Query returned no data!');
}
// Write RSS and Atom
const posts = result.data.allMarkdownRemark.edges.map(item => item.node);
const { siteMetadata } = result.data.site;
const now = new Date();
const author = {
name: siteMetadata.author.name,
email: siteMetadata.author.email,
link: siteMetadata.author.url,
};
const feed = new Feed({
title: siteMetadata.blogTitle,
id: `${siteMetadata.domain}/`,
description: siteMetadata.tagLine,
link: siteMetadata.domain,
copyright: `All rights reserved ${now.getFullYear()}, Scott Nonnenberg`,
feed: `${siteMetadata.domain}/atom.xml`,
author,
});
const mostRecent = posts.slice(0, POST_COUNT_FOR_FEEDS);
mostRecent.forEach(post => {
if (!post.frontmatter) {
console.error('Malformed post', post);
throw new Error('Post was missing frontmatter!');
}
if (!post.html) {
console.error('Malformed post', post);
throw new Error('Post was missing html!');
}
const data = post.frontmatter;
if (!data.title || !data.date || !data.path) {
console.error('Malformed post', post);
throw new Error('Post metadata was missing title, date, or path');
}
const htmlPreview = post.htmlPreview;
if (!htmlPreview) {
console.error('Malformed post', post);
throw new Error('Post metadata was missing htmlPreview');
}
const description = fixLocalLinks(htmlPreview, siteMetadata.domain);
const link = `${siteMetadata.domain}${data.path}`;
feed.addItem({
title: data.title,
link,
description,
content: fixLocalLinks(post.html, siteMetadata.domain),
date: new Date(data.date),
author: [author],
});
});
const rssPath = join(__dirname, 'public/rss.xml');
const atomPath = join(__dirname, 'public/atom.xml');
writeFileSync(rssPath, feed.rss2());
writeFileSync(atomPath, feed.atom1());
// Write JSON
const json = posts.map(post => {
if (!post.frontmatter) {
console.error('Malformed post', post);
throw new Error('Post was missing frontmatter!');
}
const data = post.frontmatter;
if (!data.path) {
console.error('Malformed post', post);
throw new Error('Post metadata was missing path');
}
const htmlPreview = post.htmlPreview;
if (!htmlPreview) {
console.error('Malformed post', post);
throw new Error('Post metadata was missing htmlPreview');
}
const preview = fixLocalLinks(htmlPreview, siteMetadata.domain);
const url = `${siteMetadata.domain}${data.path}`;
return {
title: post.frontmatter.title,
date: post.frontmatter.date,
preview,
url,
tags: post.frontmatter.tags,
};
});
const allPath = join(__dirname, 'public/all.json');
const recentPath = join(__dirname, 'public/recent.json');
writeFileSync(allPath, JSON.stringify(json, null, ' '));
writeFileSync(
recentPath,
JSON.stringify(json.slice(0, RECENT_COUNT_FOR_SYNDICATION), null, ' ')
);
},
};
export default gatsbyNode;