1
0
mirror of synced 2025-12-19 18:10:59 -05:00
Files
docs/lib/page.js
Peter Bengtsson 07c8fc3c2a Decouple redirects from language (#24597)
* experimenting with redirects

* cleanup developer.json

* wip

* clean console.log

* progress

* some progress

* progress

* much progress

* debugging tests

* hacky progress

* ditch latest -> number redirects

* minor

* hacky progress

* lots of progress

* some small fixes

* fix rendering tests

* small fixes

* progress

* undo debugging

* better

* routing tests OK

* more cleaning

* unit tests

* undoing lineending edit

* undoing temporary debugging

* don't ever set this.redirects on Page

* cope with archived version redirects

* adding code comments on the major if statements

* address all feedback

* update README about redirects

* delete invalid test

* fix feedback
2022-02-14 20:19:10 +00:00

342 lines
11 KiB
JavaScript

import assert from 'assert'
import path from 'path'
import cheerio from 'cheerio'
import patterns from './patterns.js'
import getApplicableVersions from './get-applicable-versions.js'
import generateRedirectsForPermalinks from './redirects/permalinks.js'
import getEnglishHeadings from './get-english-headings.js'
import getTocItems from './get-toc-items.js'
import pathUtils from './path-utils.js'
import Permalink from './permalink.js'
import languages from './languages.js'
import renderContent from './render-content/index.js'
import processLearningTracks from './process-learning-tracks.js'
import { productMap } from './all-products.js'
import slash from 'slash'
import readFileContents from './read-file-contents.js'
import getLinkData from './get-link-data.js'
import getDocumentType from './get-document-type.js'
import { union } from 'lodash-es'
// Wrapper on renderContent() that caches the output depending on the
// `context` by extracting information about the page's current permalink
const _renderContentCache = new Map()
function renderContentCacheByContext(prefix) {
return async function (template = '', context = {}, options = {}) {
const { currentPath } = context
const cacheKey = prefix + currentPath
if (!_renderContentCache.has(cacheKey)) {
_renderContentCache.set(cacheKey, await renderContent(template, context, options))
}
return _renderContentCache.get(cacheKey)
}
}
class Page {
static async init(opts) {
opts = await Page.read(opts)
if (!opts) return
return new Page(opts)
}
static async read(opts) {
assert(opts.languageCode, 'languageCode is required')
assert(opts.relativePath, 'relativePath is required')
assert(opts.basePath, 'basePath is required')
const relativePath = slash(opts.relativePath)
const fullPath = slash(path.join(opts.basePath, relativePath))
// Per https://nodejs.org/api/fs.html#fs_fs_exists_path_callback
// its better to read and handle errors than to check access/stats first
try {
const { data, content, errors: frontmatterErrors } = await readFileContents(fullPath)
return {
...opts,
relativePath,
fullPath,
...data,
markdown: content,
frontmatterErrors,
}
} catch (err) {
if (err.code === 'ENOENT') return false
console.error(err)
}
}
constructor(opts) {
Object.assign(this, { ...opts })
if (this.frontmatterErrors.length) {
throw new Error(JSON.stringify(this.frontmatterErrors, null, 2))
}
// Store raw data so we can cache parsed versions
this.rawIntro = this.intro
this.rawTitle = this.title
this.rawShortTitle = this.shortTitle
this.rawProduct = this.product
this.rawPermissions = this.permissions
this.rawLearningTracks = this.learningTracks
this.rawIncludeGuides = this.includeGuides
this.raw_product_video = this.product_video
if (this.introLinks) {
this.introLinks.rawQuickstart = this.introLinks.quickstart
this.introLinks.rawReference = this.introLinks.reference
this.introLinks.rawOverview = this.introLinks.overview
}
// Is this the Homepage or a Product, Category, Topic, or Article?
this.documentType = getDocumentType(this.relativePath)
// Get array of versions that the page is available in for fast lookup
this.applicableVersions = getApplicableVersions(this.versions, this.fullPath)
// a page should only be available in versions that its parent product is available in
const versionsParentProductIsNotAvailableIn = this.applicableVersions
// only the homepage will not have this.parentProduct
.filter(
(availableVersion) =>
this.parentProduct && !this.parentProduct.versions.includes(availableVersion)
)
if (versionsParentProductIsNotAvailableIn.length) {
throw new Error(
`\`versions\` frontmatter in ${this.fullPath} contains ${versionsParentProductIsNotAvailableIn}, which ${this.parentProduct.id} product is not available in!`
)
}
// derive array of Permalink objects
this.permalinks = Permalink.derive(
this.languageCode,
this.relativePath,
this.title,
this.applicableVersions
)
if (this.relativePath.endsWith('index.md')) {
// get an array of linked items in product and category TOCs
this.tocItems = getTocItems(this)
}
// if this is an article and it doesn't have showMiniToc = false, set mini TOC to true
if (!this.relativePath.endsWith('index.md')) {
this.showMiniToc = this.showMiniToc === false ? this.showMiniToc : true
}
this.render = this._render.bind(this)
return this
}
buildRedirects() {
if (!this.redirect_from) {
return {}
}
// this.redirect_from comes from frontmatter Yaml
// E.g `redirect_from: /old/path`
const redirectFrontmatter = Array.isArray(this.redirect_from)
? this.redirect_from
: [this.redirect_from]
return generateRedirectsForPermalinks(this.permalinks, redirectFrontmatter)
}
// Infer the parent product ID from the page's relative file path
get parentProductId() {
// Each page's top-level content directory matches its product ID
const id = this.relativePath.split('/')[0]
// ignore top-level content/index.md
if (id === 'index.md') return null
// make sure the ID is valid
if (process.env.NODE_ENV !== 'test') {
assert(
Object.keys(productMap).includes(id),
`page ${this.fullPath} has an invalid product ID: ${id}`
)
}
return id
}
get parentProduct() {
return productMap[this.parentProductId]
}
async renderTitle(context, opts = { preferShort: true }) {
return opts.preferShort && this.shortTitle
? this.renderProp('shortTitle', context, opts)
: this.renderProp('title', context, opts)
}
async _render(context) {
// use English IDs/anchors for translated headings, so links don't break (see #8572)
if (this.languageCode !== 'en') {
const englishHeadings = getEnglishHeadings(this, context)
context.englishHeadings = englishHeadings
}
this.intro = await renderContentCacheByContext('intro')(this.rawIntro, context)
this.introPlainText = await renderContentCacheByContext('rawIntro')(this.rawIntro, context, {
textOnly: true,
})
this.title = await renderContentCacheByContext('rawTitle')(this.rawTitle, context, {
textOnly: true,
encodeEntities: true,
})
this.titlePlainText = await renderContentCacheByContext('titleText')(this.rawTitle, context, {
textOnly: true,
})
this.shortTitle = await renderContentCacheByContext('shortTitle')(this.shortTitle, context, {
textOnly: true,
encodeEntities: true,
})
this.product_video = await renderContent(this.raw_product_video, context, { textOnly: true })
if (this.introLinks) {
this.introLinks.quickstart = await renderContent(this.introLinks.rawQuickstart, context, {
textOnly: true,
})
this.introLinks.reference = await renderContent(this.introLinks.rawReference, context, {
textOnly: true,
})
this.introLinks.overview = await renderContent(this.introLinks.rawOverview, context, {
textOnly: true,
})
}
context.relativePath = this.relativePath
const html = await renderContentCacheByContext('markdown')(this.markdown, context)
// Adding communityRedirect for Discussions, Sponsors, and Codespaces - request from Product
if (
this.parentProduct &&
(this.parentProduct.id === 'discussions' ||
this.parentProduct.id === 'sponsors' ||
this.parentProduct.id === 'codespaces')
) {
this.communityRedirect = {
name: 'Provide GitHub Feedback',
href: `https://github.com/github/feedback/discussions/categories/${this.parentProduct.id}-feedback`,
}
}
// product frontmatter may contain liquid
if (this.rawProduct) {
this.product = await renderContentCacheByContext('product')(this.rawProduct, context)
}
// permissions frontmatter may contain liquid
if (this.rawPermissions) {
this.permissions = await renderContentCacheByContext('permissions')(
this.rawPermissions,
context
)
}
// Learning tracks may contain Liquid and need to have versioning processed.
if (this.rawLearningTracks) {
const { featuredTrack, learningTracks } = await processLearningTracks(
this.rawLearningTracks,
context
)
this.featuredTrack = featuredTrack
this.learningTracks = learningTracks
}
if (this.rawIncludeGuides) {
this.allTopics = []
this.includeGuides = await getLinkData(this.rawIncludeGuides, context)
this.includeGuides.map((guide) => {
const { page } = guide
guide.type = page.type
if (page.topics) {
this.allTopics = union(this.allTopics, page.topics).sort((a, b) =>
a.localeCompare(b, page.languageCode)
)
guide.topics = page.topics
}
delete guide.page
return guide
})
}
// set a flag so layout knows whether to render a mac/windows/linux switcher element
this.detectedPlatforms = ['mac', 'windows', 'linux'].filter(
(platform) =>
html.includes(`extended-markdown ${platform}`) || html.includes(`platform-${platform}`)
)
this.includesPlatformSpecificContent = this.detectedPlatforms.length > 0
// set flags for webui, cli, etc switcher element
this.detectedTools = [
'cli',
'desktop',
'webui',
'curl',
'codespaces',
'vscode',
`importer_cli`,
`graphql`,
].filter((tool) => html.includes(`extended-markdown ${tool}`) || html.includes(`tool-${tool}`))
this.includesToolSpecificContent = this.detectedTools.length > 0
return html
}
// Allow other modules (like custom liquid tags) to make one-off requests
// for a page's rendered properties like `title` and `intro`
async renderProp(propName, context, opts = { unwrap: false }) {
let prop
if (propName === 'title') {
prop = this.rawTitle
} else if (propName === 'shortTitle') {
prop = this.rawShortTitle || this.rawTitle // fall back to title
} else if (propName === 'intro') {
prop = this.rawIntro
} else {
prop = this[propName]
}
const html = await renderContent(prop, context, opts)
if (!opts.unwrap) return html
// The unwrap option removes surrounding tags from a string, preserving any inner HTML
const $ = cheerio.load(html, { xmlMode: true })
return $.root().contents().html()
}
// infer current page's corresponding homepage
// /en/articles/foo -> /en
// /en/enterprise/2.14/user/articles/foo -> /en/enterprise/2.14/user
static getHomepage(requestPath) {
return requestPath.replace(/\/articles.*/, '')
}
// given a page path, return an array of objects containing hrefs
// for that page in all languages
static getLanguageVariants(href) {
const suffix = pathUtils.getPathWithoutLanguage(href)
return Object.values(languages).map(({ name, code, hreflang }) => {
// eslint-disable-line
return {
name,
code,
hreflang,
href: `/${code}${suffix}`.replace(patterns.trailingSlash, '$1'),
}
})
}
}
export default Page