| | import fs from 'fs' |
| | import path from 'path' |
| |
|
| | import { visit, Test } from 'unist-util-visit' |
| | import { fromMarkdown } from 'mdast-util-from-markdown' |
| | import { toMarkdown } from 'mdast-util-to-markdown' |
| | import yaml from 'js-yaml' |
| | import { type Node, type Nodes, type Definition, type Link } from 'mdast' |
| |
|
| | import frontmatter from '@/frame/lib/read-frontmatter' |
| | import { |
| | getPathWithLanguage, |
| | getPathWithoutLanguage, |
| | getPathWithoutVersion, |
| | getVersionStringFromPath, |
| | } from '@/frame/lib/path-utils' |
| | import loadRedirects from '@/redirects/lib/precompile' |
| | import patterns from '@/frame/lib/patterns' |
| | import { loadUnversionedTree, loadPages, loadPageMap } from '@/frame/lib/page-data' |
| | import getRedirect, { splitPathByLanguage } from '@/redirects/lib/get-redirect' |
| | import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' |
| | import { deprecated } from '@/versions/lib/enterprise-server-releases' |
| |
|
| | |
| | |
| | const AUTOTITLE = 'AUTOTITLE' |
| |
|
| | const Options = { |
| | setAutotitle: false, |
| | fixHref: false, |
| | verbose: false, |
| | strict: false, |
| | } |
| |
|
| | export async function updateInternalLinks(files: string[], options = {}) { |
| | const opts = Object.assign({}, Options, options) |
| |
|
| | const results = [] |
| |
|
| | const unversionedTree = await loadUnversionedTree(['en']) |
| | const pageList = await loadPages(unversionedTree, ['en']) |
| | const pageMap = await loadPageMap(pageList) |
| | const redirects = await loadRedirects(pageList) |
| |
|
| | const context = { |
| | pages: pageMap, |
| | redirects, |
| | currentLanguage: 'en', |
| | userLanguage: 'en', |
| | } |
| |
|
| | for (const file of files) { |
| | try { |
| | results.push({ |
| | file, |
| | ...(await updateFile(file, context, opts)), |
| | }) |
| | } catch (err) { |
| | console.warn(`The file it tried to process on exception was: ${file}`) |
| | throw err |
| | } |
| | } |
| |
|
| | return results |
| | } |
| |
|
| | async function updateFile( |
| | file: string, |
| | context: { |
| | // Using any because page data structures vary by page type (articles, guides, etc.) |
| | pages: Record<string, any> |
| | // Using any because redirects can be strings or redirect objects with various properties |
| | redirects: any |
| | currentLanguage: string |
| | userLanguage: string |
| | }, |
| | opts: typeof Options, |
| | ) { |
| | const rawContent = fs.readFileSync(file, 'utf8') |
| | let { data, content } = frontmatter(rawContent) |
| | data = data || {} |
| | content = content || '' |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | if (file.endsWith('.yml')) { |
| | Object.assign(data, yaml.load(content)) |
| | } |
| |
|
| | let newContent = content |
| | const ast = fromMarkdown(newContent) |
| |
|
| | |
| | const replacements: any[] = [] |
| | |
| | const warnings: any[] = [] |
| |
|
| | const newData = structuredClone(data) |
| |
|
| | const ANY = Symbol('any') |
| | const IS_ARRAY = Symbol('is array') |
| |
|
| | |
| | |
| | |
| | const HAS_LINKS: Record<string, any> = { |
| | featuredLinks: ['gettingStarted', 'startHere', 'guideCards', 'popular'], |
| | introLinks: ANY, |
| | includeGuides: IS_ARRAY, |
| | } |
| |
|
| | if ( |
| | file.split(path.sep).includes('data') && |
| | file.split(path.sep).includes('learning-tracks') && |
| | file.endsWith('.yml') |
| | ) { |
| | |
| | |
| | |
| | for (const key of Object.keys(data)) { |
| | HAS_LINKS[key] = ['guides'] |
| | } |
| | } |
| |
|
| | for (const [key, seek] of Object.entries(HAS_LINKS)) { |
| | if (!(key in data)) { |
| | continue |
| | } |
| | try { |
| | if (Array.isArray(data[key])) { |
| | if ((Array.isArray(seek) && seek.includes(key)) || seek === IS_ARRAY || seek === ANY) { |
| | const better = getNewFrontmatterLinkList(data[key], context, opts, file, rawContent) |
| | if (!equalArray(better, data[key])) { |
| | newData[key] = better |
| | } |
| | } |
| | } else { |
| | for (const [group, thing] of Object.entries(data[key])) { |
| | if (Array.isArray(thing)) { |
| | if ( |
| | (Array.isArray(seek) && seek.includes(group)) || |
| | seek === IS_ARRAY || |
| | seek === ANY |
| | ) { |
| | const better = getNewFrontmatterLinkList(thing, context, opts, file, rawContent) |
| | if (!equalArray(better, thing)) { |
| | newData[key][group] = better |
| | } |
| | } |
| | } else if (typeof thing === 'string' && thing.startsWith('/')) { |
| | const better = getNewFrontmatterLinkList([thing], context, opts, file, rawContent) |
| | if (!equalArray(better, [thing])) { |
| | newData[key][group] = better[0] |
| | } |
| | } |
| | } |
| | } |
| | } catch (error) { |
| | |
| | |
| | |
| | |
| | console.warn(`The frontmatter key it processed and failed was '${key}'`) |
| | throw error |
| | } |
| | } |
| |
|
| | const lineOffset = rawContent.replace(content, '').split(/\n/g).length - 1 |
| |
|
| | visit(ast, definitionMatcher as Test, (node: Nodes) => { |
| | const asMarkdown = toMarkdown(node).trim() |
| | |
| | if (opts.fixHref && content.includes(asMarkdown) && isDefinition(node)) { |
| | let newHref = node.url |
| | const { label } = node |
| | const betterHref = getNewHref(newHref, context, opts, file) |
| | |
| | |
| | if (betterHref !== undefined) { |
| | newHref = betterHref |
| | } |
| | const newAsMarkdown = `[${label}]: ${newHref}` |
| | if (asMarkdown !== newAsMarkdown) { |
| | |
| | const column = node.position?.start.column |
| | const line = node.position?.start.line || 0 + lineOffset |
| | replacements.push({ |
| | asMarkdown, |
| | newAsMarkdown, |
| | line, |
| | column, |
| | }) |
| | newContent = newContent.replace(asMarkdown, newAsMarkdown) |
| | } |
| | } |
| | }) |
| |
|
| | visit(ast, linkMatcher as Test, (node: Nodes) => { |
| | const asMarkdown = toMarkdown(node).trim() |
| | if (content.includes(asMarkdown) && isLink(node)) { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | const title = node.children.map((child: Nodes) => toMarkdown(child).slice(0, -1)).join('') |
| |
|
| | let newTitle = title |
| | let newHref = node.url |
| |
|
| | const hasQuotesAroundLink = content.includes(`"${asMarkdown}`) |
| |
|
| | const xValue = (node?.children?.[0] as any)?.value |
| |
|
| | if (opts.setAutotitle) { |
| | if (hasQuotesAroundLink) { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | if (title !== AUTOTITLE) { |
| | newTitle = AUTOTITLE |
| | } |
| | } else { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | if (xValue) { |
| | if (singleStartingQuote(xValue)) { |
| | const column = node.position?.start.column |
| | const line = node.position?.start.line || 0 + lineOffset |
| | warnings.push({ |
| | warning: 'Starts with a single " inside the text', |
| | asMarkdown, |
| | line, |
| | column, |
| | }) |
| | } else if (isSimpleQuote(xValue)) { |
| | const column = node.position?.start.column |
| | const line = node.position?.start.line || 0 + lineOffset |
| | warnings.push({ |
| | warning: 'Starts and ends with a " inside the text', |
| | asMarkdown, |
| | line, |
| | column, |
| | }) |
| | } |
| | } |
| | } |
| | } |
| | if (opts.fixHref) { |
| | const betterHref = getNewHref(node.url, context, opts, file) |
| | |
| | |
| | if (betterHref !== undefined) { |
| | newHref = betterHref |
| | } |
| | } |
| | const newAsMarkdown = `[${newTitle}](${newHref})` |
| | if (asMarkdown !== newAsMarkdown) { |
| | |
| | const column = node.position?.start.column |
| | const line = node.position?.start.line || 0 + lineOffset |
| | replacements.push({ |
| | asMarkdown, |
| | newAsMarkdown, |
| | line, |
| | column, |
| | }) |
| | newContent = newContent.replace(asMarkdown, newAsMarkdown) |
| | } |
| | } else if (opts.verbose) { |
| | console.warn( |
| | `Unable to find link as Markdown ('${asMarkdown}') in the source content (${file})`, |
| | ) |
| | } |
| | }) |
| |
|
| | return { |
| | data, |
| | content, |
| | rawContent, |
| | newContent, |
| | replacements, |
| | warnings, |
| | newData, |
| | } |
| | } |
| |
|
| | function isDefinition(node: Node): node is Definition { |
| | return node.type === 'definition' |
| | } |
| |
|
| | function isLink(node: Node): node is Link { |
| | return node.type === 'link' |
| | } |
| |
|
| | function definitionMatcher(node: Node) { |
| | if (!isDefinition(node)) return false |
| | const { url } = node |
| | if (url) { |
| | return url.startsWith('/') |
| | } |
| | return false |
| | } |
| |
|
| | function linkMatcher(node: Node) { |
| | if (isLink(node) && node.url) { |
| | const { url } = node |
| | if (url.startsWith('/') || url.startsWith('./')) { |
| | |
| | |
| | if (url.startsWith('/assets') || url.startsWith('/public/')) { |
| | return false |
| | } |
| |
|
| | |
| | |
| | if (url.includes('{{') || url.includes('{%')) { |
| | return false |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | const version = getVersionStringFromPath(url) |
| | if ( |
| | version && |
| | version.startsWith('enterprise-server@') && |
| | deprecated.includes(version.replace('enterprise-server@', '')) |
| | ) { |
| | return false |
| | } |
| |
|
| | |
| | |
| | |
| | if (patterns.getEnterpriseVersionNumber.test(url)) { |
| | return false |
| | } |
| |
|
| | return true |
| | } |
| | } |
| | return false |
| | } |
| |
|
| | function getNewFrontmatterLinkList( |
| | |
| | list: any[], |
| | context: { |
| | // Using any because page data structures vary by page type |
| | pages: Record<string, any> |
| | // Using any because redirects can be strings or redirect objects |
| | redirects: any |
| | currentLanguage: string |
| | userLanguage: string |
| | }, |
| | opts: { |
| | setAutotitle: boolean |
| | fixHref: boolean |
| | verbose: boolean |
| | strict: boolean |
| | }, |
| | file: string, |
| | rawContent: string, |
| | ) { |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | const better = [] |
| | for (const entry of list) { |
| | if (/{%\s*else\s*%}/.test(entry)) { |
| | console.warn(`Skipping frontmatter link with {% else %} in it: ${entry}. (file: ${file})`) |
| | better.push(entry) |
| | continue |
| | } |
| | const pure = stripLiquid(entry) |
| | let asURL = '/en' |
| | if (!pure.startsWith('/')) { |
| | asURL += '/' |
| | } |
| | asURL += pure |
| | if (asURL in context.pages) { |
| | better.push(entry) |
| | } else { |
| | const redirected = getRedirect(asURL, context) |
| | if (redirected === undefined) { |
| | const lineNumber = findLineNumber(entry, rawContent) |
| | const msg = |
| | 'A frontmatter link appears to be broken. ' + |
| | `Neither redirect or a findable page: ${pure}. (file: ${file} line: ${ |
| | lineNumber || 'unknown' |
| | })` |
| |
|
| | if (opts.strict) { |
| | throw new Error(msg) |
| | } |
| | console.warn(`WARNING: ${msg}`) |
| | better.push(entry) |
| | } else { |
| | |
| | const redirectedWithoutLanguage = getPathWithoutLanguage(redirected) |
| | const asURLWithoutVersion = getPathWithoutVersion(redirectedWithoutLanguage) |
| | if (asURLWithoutVersion === pure) { |
| | better.push(entry) |
| | } else { |
| | better.push(entry.replace(pure, asURLWithoutVersion)) |
| | } |
| | } |
| | } |
| | } |
| | return better |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function findLineNumber(entry: any, rawContent: string) { |
| | let number = 0 |
| | for (const line of rawContent.split(/\n/g)) { |
| | number++ |
| | if (line.endsWith(entry) && line.includes(` ${entry}`)) { |
| | return number |
| | } |
| | } |
| |
|
| | return null |
| | } |
| |
|
| | const liquidStartRex = /^{%-?\s*ifversion .+?\s*%}/ |
| | const liquidEndRex = /{%-?\s*endif\s*-?%}$/ |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | function stripLiquid(text: string) { |
| | if (liquidStartRex.test(text) && liquidEndRex.test(text)) { |
| | return text.replace(liquidStartRex, '').replace(liquidEndRex, '').trim() |
| | } else if (text.includes('{')) { |
| | throw new Error(`Unsupported Liquid in frontmatter link list (${text})`) |
| | } |
| | return text |
| | } |
| |
|
| | |
| | function equalArray(arr1: any[], arr2: any[]) { |
| | return arr1.length === arr2.length && arr1.every((item, i) => item === arr2[i]) |
| | } |
| |
|
| | function getNewHref( |
| | href: string, |
| | context: { |
| | // Using any because page data structures vary by page type |
| | pages: Record<string, any> |
| | // Using any because redirects can be strings or redirect objects |
| | redirects: any |
| | currentLanguage: string |
| | userLanguage: string |
| | }, |
| | opts: { |
| | setAutotitle: boolean |
| | fixHref: boolean |
| | verbose: boolean |
| | strict: boolean |
| | }, |
| | file: string, |
| | ) { |
| | const { currentLanguage } = context |
| | const parsed = new URL(href, 'https://docs.github.com') |
| | const hash = parsed.hash |
| | const search = parsed.search |
| | const pure = parsed.pathname |
| | let newHref = pure.replace(patterns.trailingSlash, '$1') |
| |
|
| | |
| | |
| | const [language, withoutLanguage] = splitPathByLanguage(newHref, currentLanguage) |
| | if (withoutLanguage !== newHref) { |
| | |
| | const msg = `Unable to cope with internal links with hardcoded language '${newHref}' (file: ${file})` |
| | if (opts.strict) { |
| | throw new Error(msg) |
| | } else { |
| | console.warn(`WARNING: ${msg}`) |
| | return |
| | } |
| | } |
| | const newHrefWithLanguage = getPathWithLanguage(withoutLanguage, language) |
| | const redirected = getRedirect(newHrefWithLanguage, context) |
| |
|
| | |
| | |
| | |
| | |
| | if (redirected === undefined) { |
| | if (!context.pages[newHrefWithLanguage]) { |
| | |
| | const msg = `A link appears to be broken. Neither redirect or a findable page '${href}' (${file})` |
| | if (opts.strict) { |
| | throw new Error(msg) |
| | } else { |
| | console.warn(`WARNING: ${msg}`) |
| | return |
| | } |
| | } |
| | } |
| |
|
| | if (redirected) { |
| | |
| | |
| | |
| | |
| | |
| | |
| | const redirectedWithoutLanguage = getPathWithoutLanguage(redirected) |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | if (withoutLanguage.includes(`/${nonEnterpriseDefaultVersion}/`)) { |
| | newHref = `/${nonEnterpriseDefaultVersion}${redirectedWithoutLanguage}` |
| | } else if (withoutLanguage.startsWith('/enterprise-server/')) { |
| | const msg = |
| | "Old /enterprise-server/ links that don't include a @version are no longer supported. " + |
| | 'If you see this, manually fix that link to use enterprise-server@latest.' |
| | if (opts.strict) { |
| | throw new Error(msg) |
| | } else { |
| | console.warn(msg) |
| | return |
| | } |
| | } else if (withoutLanguage.startsWith('/enterprise-server@latest')) { |
| | |
| | |
| | |
| | newHref = `/enterprise-server@latest${getPathWithoutVersion(redirectedWithoutLanguage)}` |
| | } else if (getPathWithoutVersion(withoutLanguage) !== withoutLanguage) { |
| | newHref = redirectedWithoutLanguage |
| | } else { |
| | newHref = getPathWithoutVersion(redirectedWithoutLanguage) |
| | } |
| | } |
| |
|
| | if (search) { |
| | newHref += search |
| | } |
| | if (hash) { |
| | newHref += hash |
| | } |
| | return newHref |
| | } |
| |
|
| | function singleStartingQuote(text: string) { |
| | return text.startsWith('"') && text.split('"').length === 2 |
| | } |
| |
|
| | function isSimpleQuote(text: string) { |
| | return text.startsWith('"') && text.endsWith('"') && text.split('"').length === 3 |
| | } |
| |
|