-
Notifications
You must be signed in to change notification settings - Fork 67.1k
Expand file tree
/
Copy pathcreate-tree.ts
More file actions
201 lines (185 loc) · 8.37 KB
/
create-tree.ts
File metadata and controls
201 lines (185 loc) · 8.37 KB
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
import path from 'path'
import fs from 'fs/promises'
import PageClass from './page'
import type { UnversionedTree, Page } from '@/types'
import { createLogger } from '@/observability/logger'
const logger = createLogger(import.meta.url)
const isProduction = process.env.NODE_ENV === 'production'
export default async function createTree(
originalPath: string,
rootPath?: string,
previousTree?: UnversionedTree,
): Promise<UnversionedTree | undefined> {
const basePath = rootPath || originalPath
// On recursive runs, this is processing page.children items in `/<link>` format.
// If the path exists as is, assume this is a directory with a child index.md.
// Otherwise, assume it's a child .md file and add `.md` to the path.
let filepath: string
let mtime: number
// This kills two birds with one stone. We (attempt to) read it as a file,
// to find out if it's a directory or a file and whence we know that
// we also collect it's modification time.
try {
filepath = `${originalPath}.md`
mtime = await getMtime(filepath)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error
}
filepath = `${originalPath}/index.md`
// Note, if this throws, that's quite fine. It usually means that
// there's a `index.md` whose `children:` entry lists something that
// doesn't exist on disk. So the writer who tries to review the
// page will see the error and it's hopefully clear what's actually
// wrong.
try {
mtime = await getMtime(filepath)
} catch (innerError) {
if ((innerError as NodeJS.ErrnoException).code !== 'ENOENT') {
throw innerError
}
// Throw an error if we can't find a content file associated with the children: entry.
// But don't throw an error if the user is running the site locally and hasn't cloned the Early Access repo.
// Also don't throw for missing children *within* early-access content — a broken
// early-access article should not block every docs-internal PR from merging.
const msg = `Cannot find a content file at ${originalPath}. Check the 'children' frontmatter in the parent index.md.`
if (
originalPath === 'content/early-access' ||
originalPath.startsWith('content/early-access/')
) {
logger.warn(msg, { path: originalPath })
return
}
throw new Error(msg)
}
}
const relativePath = filepath.replace(`${basePath}/`, '')
// Reading in a file from disk is slow and best avoided if we can be
// certain it isn't necessary. If the previous tree is known and that
// tree's page node's `mtime` hasn't changed, we can use that instead.
let page: Page
if (previousTree && previousTree.page.mtime === mtime) {
// A save! We can use the same exact Page instance from the previous
// tree because the assumption is that since the `.md` file it was
// created from hasn't changed (on disk) the instance object wouldn't
// change.
page = previousTree.page
} else {
// Either the previous tree doesn't exist yet or the modification time
// of the file on disk has changed.
const newPage = await PageClass.init({
basePath,
relativePath,
languageCode: 'en',
})
if (!newPage) {
throw Error(`Cannot initialize page for ${filepath}`)
}
page = newPage as unknown as Page
}
// Create the root tree object on the first run, and create children recursively.
const item: UnversionedTree = {
page,
// This is only here for the sake of reloading the tree later which
// only happens in development mode.
// The reloading of the tree compares the list of children (array of
// strings) with what it might have been in the previous tree.
// Then it can use the "n'th" access to figure out what the
// "previous sub tree" was for each child.
// So if a writer edits the 'children:' frontmatter property
// this value now will be different from what it was before.
// It's not enough to rely on *length* of the array before and after
// because the change could have been to remove one and add another.
// Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
children: page.children || [],
childPages: [],
}
// Process frontmatter children recursively.
if (page.children) {
assertUniqueChildren(page)
item.childPages = (
await Promise.all(
(page.children as string[]).map(async (child: string, i: number) => {
let childPreviousTree: UnversionedTree | undefined
if (previousTree && previousTree.childPages) {
if (equalArray(page.children as string[], previousTree.children)) {
// We can only safely rely on picking the same "n'th" item
// from the array if we're confident the names are the same
// as they were before.
// Otherwise, suppose you add an entry to `children:`
// and add another, then length would be the same but
// each position might relate to different child.
childPreviousTree = previousTree.childPages[i]
}
}
// Handle absolute /content/ paths - allows cross-product directory inclusion
// e.g., /content/actions/workflows will include the entire actions/workflows tree
let childPath: string
if (child.startsWith('/content/')) {
// Absolute content path - resolve from the content root
// Strip '/content/' prefix and join with the base content directory
const absoluteChildPath = child.slice('/content/'.length)
childPath = path.posix.join(basePath, absoluteChildPath)
// Security check: ensure the resolved path stays within the content directory
// This prevents path traversal attacks using sequences like '../'
const resolvedPath = path.resolve(childPath)
const resolvedBasePath = path.resolve(basePath)
if (!resolvedPath.startsWith(resolvedBasePath + path.sep)) {
throw new Error(
`Invalid child path "${child}" in ${originalPath}/index.md - path traversal detected. ` +
`Resolved path "${resolvedPath}" escapes content directory "${resolvedBasePath}".`,
)
}
} else {
// Traditional relative path
childPath = path.posix.join(originalPath, child)
}
const subTree = await createTree(childPath, basePath, childPreviousTree)
if (subTree && child.startsWith('/content/')) {
// Mark this subtree as a cross-product child so it can be excluded from the sidebar
subTree.crossProductChild = true
}
if (!subTree) {
// Remove that children.
// For example, the 'early-access' might have been in the
// `children:` property but it was decided to be skipped
// (early exit instead of returning a tree). So let's
// mutate the `page.children` so we can benefit from the
// ability to reload the site tree on consecutive requests.
// Page class has dynamic frontmatter properties like 'children' that aren't in the type definition
;(page.children as string[]) = (page.children as string[]).filter(
(c: string) => c !== child,
)
}
return subTree
}),
)
).filter((tree): tree is UnversionedTree => tree !== undefined)
}
return item
}
function equalArray(arr1: string[], arr2: string[]): boolean {
return arr1.length === arr2.length && arr1.every((value, i) => value === arr2[i])
}
async function getMtime(filePath: string): Promise<number> {
if (isProduction) {
// In production, skip the full stat but still verify existence
await fs.access(filePath)
return 1
}
return Math.round((await fs.stat(filePath)).mtimeMs)
}
function assertUniqueChildren(page: Page): void {
const children = page.children || []
if (children.length !== new Set(children).size) {
const count: Record<string, number> = {}
for (const entry of children) {
count[entry] = 1 + (count[entry] || 0)
}
let msg = `${page.relativePath} has duplicates in the 'children' key.`
for (const [entry, times] of Object.entries(count)) {
if (times > 1) msg += ` '${entry}' is repeated ${times} times. `
}
throw new Error(msg)
}
}