1 import {join, relative} from 'path'
2 import {Octokit as CoreOctokit} from '@octokit/rest'
3 import {throttling} from '@octokit/plugin-throttling'
4 import {retry} from '@octokit/plugin-retry'
5 import webpackConfig from './webpack.config.js'
6
7 const CI = !!process.env.CI
8 const CWD = process.cwd()
9 const SRC = join(CWD, 'src')
10 const REPO_URL = 'https://github.com/npm/documentation'
11 const NWO = new URL(REPO_URL).pathname.slice(1)
12 const REPO_BRANCH = 'main'
13 const TEST_CONTRIBUTORS = [
14 {
15 author: {login: 'mona'},
16 commit: {author: {date: new Date('2023-03-21').toJSON()}},
17 html_url: REPO_URL,
18 },
19 ]
20
21 const createOctokit = ({reporter}) => {
22 const Octokit = CoreOctokit.plugin(throttling).plugin(retry)
23 return new Octokit({
24 log: {
25 debug: () => {},
26 info: reporter.info,
27 warn: reporter.warn,
28 error: reporter.error,
29 },
30 auth: process.env.GITHUB_TOKEN,
31 throttle: {
32 onRateLimit: (retryAfter, options, {log}, retryCount) => {
33 log.warn(`Request quota exhausted for request ${options.method} ${options.url}`)
34 if (retryCount < 2) {
35 log.info(`Retrying after ${retryAfter} seconds`)
36 return true
37 }
38 },
39 onSecondaryRateLimit: (_, options, {log}) => {
40 log.warn(`SecondaryRateLimit detected for request ${options.method} ${options.url}`)
41 },
42 },
43 })
44 }
45
46 export const onCreateNode = ({node, actions, getNode}) => {
47 if (node.internal.type === 'Mdx') {
48 const {name, relativeDirectory: dir} = getNode(node.parent)
49
50 // These paths are unchanged:
51 // - directory indexes
52 // - all cli paths
53 // - all policies paths
54 if (name === 'index' || dir.startsWith('cli/') || dir.startsWith('policies')) {
55 return
56 }
57
58 // otherwise, omit the directory path and use the filename as the slug
59 actions.createNodeField({
60 name: 'slug',
61 node,
62 value: name,
63 })
64 }
65 }
66
67 export const onCreateWebpackConfig = ({stage, actions}) => {
68 actions.setWebpackConfig({
69 ...webpackConfig,
70 })
71
72 if (stage === `build-javascript`) {
73 actions.setWebpackConfig({
74 devtool: false,
75 })
76 }
77 }
78
79 export const createSchemaCustomization = ({actions: {createTypes}}) => {
80 createTypes(`
81 type Mdx implements Node {
82 frontmatter: MdxFrontmatter
83 fields: MdxFields
84 }
85 type MdxFrontmatter {
86 edit_on_github: Boolean,
87 github_branch: String,
88 github_path: String,
89 github_repo: String,
90 redirect_from: [String],
91 slug: String,
92 title: String
93 }
94 type MdxFields {
95 slug: String
96 }
97 `)
98 }
99
100 export const createPages = async ({graphql, actions, reporter}) => {
101 const response = await graphql(`
102 {
103 allMdx {
104 nodes {
105 id
106 internal {
107 contentFilePath
108 }
109 fields {
110 slug
111 }
112 frontmatter {
113 edit_on_github
114 github_branch
115 github_path
116 github_repo
117 redirect_from
118 slug
119 title
120 }
121 tableOfContents
122 parent {
123 ... on File {
124 relativeDirectory
125 name
126 }
127 }
128 }
129 }
130 }
131 `)
132
133 if (response.errors) {
134 reporter.panic('Error getting allMdx', response.errors)
135 return
136 }
137
138 const octokit = createOctokit({reporter})
139
140 // Turn every MDX file into a page.
141 return Promise.all(
142 response.data.allMdx.nodes.map(async node => {
143 try {
144 node.fields ||= {}
145 node.frontmatter ||= {}
146 node.frontmatter.redirect_from ||= []
147 node.tableOfContents ||= {}
148 node.tableOfContents.items ||= []
149 return await createPage(node, {actions, reporter, octokit})
150 } catch (err) {
151 reporter.panic(`Error creating page: ${JSON.stringify(node, null, 2)}`, err)
152 }
153 }),
154 )
155 }
156
157 const createPage = async (
158 {
159 id,
160 internal: {contentFilePath},
161 fields: {slug},
162 frontmatter = {},
163 tableOfContents = {},
164 parent: {relativeDirectory, name: parentName},
165 },
166 {actions, reporter, octokit},
167 ) => {
168 const path = relative(CWD, contentFilePath)
169 // sites can programmatically override slug, that takes priority
170 // then a slug specified in frontmatter
171 // finally, we'll just use the path on disk
172 const pageSlug =
173 slug ?? frontmatter.slug ?? join(relativeDirectory, parentName === 'index' ? '/' : parentName).replace(/\\/g, '/')
174
175 const context = {
176 mdxId: id,
177 tableOfContents: getTableOfConents(tableOfContents),
178 }
179 // edit_on_github: false in frontmatter will not include editUrl and contributors
180 // on the page. this is used for policy pages as well as some index pages that don't
181 // have any editable content
182 if (frontmatter.edit_on_github !== false) {
183 context.editUrl = getRepo(path, frontmatter).replace(`https://github.com/{nwo}/edit/{branch}/{path}`)
184 Object.assign(context, await fetchContributors(path, frontmatter, {reporter, octokit}))
185 }
186
187 actions.createPage({
188 path: pageSlug,
189 component: `${join(SRC, 'head.js')}?__contentFilePath=${contentFilePath}`,
190 context,
191 })
192
193 for (const from of frontmatter.redirect_from) {
194 actions.createRedirect({
195 fromPath: from,
196 toPath: `/${pageSlug}`,
197 isPermanent: true,
198 redirectInBrowser: true,
199 })
200
201 if (pageSlug.startsWith('cli/') && !from.endsWith('index')) {
202 actions.createRedirect({
203 fromPath: `${from}.html`,
204 toPath: `/${pageSlug}`,
205 isPermanent: true,
206 redirectInBrowser: true,
207 })
208 }
209 }
210 }
211
212 const getTableOfConents = ({items}) => {
213 // Fix some old CLI pages which have mismatched headings at the top level.
214 // All top level headings should be the same level.
215 const tableOfContents = items.reduce((acc, item) => {
216 if (!item.url && Array.isArray(item.items)) {
217 acc.push(...item.items)
218 } else {
219 acc.push(item)
220 }
221 return acc
222 }, [])
223
224 if (tableOfContents.length) {
225 return tableOfContents
226 }
227 }
228
229 const getRepo = (path, fm) => {
230 const result = {
231 nwo: NWO,
232 branch: REPO_BRANCH,
233 ...(fm.github_repo ? {nwo: fm.github_repo} : {}),
234 ...(fm.github_branch ? {branch: fm.github_branch} : {}),
235 path: fm.github_path || path,
236 }
237 const [owner, repo] = result.nwo.split('/')
238 result.owner = owner
239 result.repo = repo
240 result.replace = str => str.replace(/\{([a-z]+)\}/g, (_, name) => result[name])
241 return result
242 }
243
244 let warnOnNoContributors = true
245 const fetchContributors = async (path, fm, {reporter, octokit}) => {
246 const noAuth = (await octokit.auth()).type === 'unauthenticated'
247 if (noAuth) {
248 const msg = `Cannot fetch contributors without GitHub authentication.`
249 if (CI) {
250 reporter.panic(msg)
251 return
252 }
253
254 if (warnOnNoContributors) {
255 warnOnNoContributors = false
256 reporter.warn(`${msg} Pages will be include test contributor data.`)
257 }
258 }
259
260 try {
261 const repo = getRepo(path, fm)
262 const resp = noAuth
263 ? {data: TEST_CONTRIBUTORS}
264 : await octokit.rest.repos.listCommits({
265 repo: repo.repo,
266 owner: repo.owner,
267 path: repo.path,
268 sha: repo.branch,
269 per_page: 100,
270 })
271
272 const contributors = new Set()
273 let latestCommit = null
274
275 for (const item of resp.data) {
276 if (item.author?.login) {
277 contributors.add(item.author.login)
278 if (!latestCommit) {
279 latestCommit = {
280 login: item.author.login,
281 date: item.commit.author.date,
282 url: item.html_url,
283 }
284 }
285 }
286 }
287
288 return {
289 contributors: [...contributors],
290 latestCommit,
291 }
292 } catch (err) {
293 reporter[CI ? 'panic' : 'error'](`Error fetching contributors for ${path}`, err)
294 }
295 }