2021-07-14 23:49:18 +03:00
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'
2022-05-23 17:48:49 +03:00
import { allTools } from './all-tools.js'
2022-12-06 20:11:41 +03:00
import { renderContentWithFallback } from './render-with-fallback.js'
2020-09-27 15:10:11 +03:00
2022-02-24 16:38:08 +03:00
// We're going to check a lot of pages' "ID" (the first part of
// the relativePath) against `productMap` to make sure it's valid.
// To avoid having to do `Object.keys(productMap).includes(id)`
// every single time, we turn it into a Set once.
const productMapKeysAsSet = new Set ( Object . keys ( productMap ) )
2022-11-17 22:11:29 +03:00
export class FrontmatterErrorsError extends Error {
constructor ( message , frontmatterErrors ) {
super ( message )
this . frontmatterErrors = frontmatterErrors
}
}
2020-09-27 15:10:11 +03:00
class Page {
2021-07-15 00:35:01 +03:00
static async init ( opts ) {
2021-01-14 18:46:59 +03:00
opts = await Page . read ( opts )
2020-12-15 21:56:25 +03:00
if ( ! opts ) return
return new Page ( opts )
}
2021-07-15 00:35:01 +03:00
static async read ( opts ) {
2021-01-14 18:46:59 +03:00
assert ( opts . languageCode , 'languageCode is required' )
2020-09-27 15:10:11 +03:00
assert ( opts . relativePath , 'relativePath is required' )
assert ( opts . basePath , 'basePath is required' )
2020-12-09 20:40:58 +03:00
const relativePath = slash ( opts . relativePath )
const fullPath = slash ( path . join ( opts . basePath , relativePath ) )
2020-12-14 19:44:09 +03:00
// 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
2020-12-09 20:40:58 +03:00
try {
2021-12-02 01:56:03 +03:00
const { data , content , errors : frontmatterErrors } = await readFileContents ( fullPath )
2021-01-14 18:46:59 +03:00
return {
... opts ,
relativePath ,
fullPath ,
... data ,
markdown : content ,
2021-07-15 00:35:01 +03:00
frontmatterErrors ,
2021-01-14 18:46:59 +03:00
}
2020-12-09 20:40:58 +03:00
} catch ( err ) {
if ( err . code === 'ENOENT' ) return false
console . error ( err )
}
}
2021-07-15 00:35:01 +03:00
constructor ( opts ) {
2022-11-21 15:37:48 +03:00
if ( opts . frontmatterErrors && opts . frontmatterErrors . length ) {
2022-11-17 22:11:29 +03:00
throw new FrontmatterErrorsError (
` ${ opts . frontmatterErrors . length } frontmatter errors trying to load ${ opts . fullPath } ` ,
opts . frontmatterErrors
)
2020-09-27 15:10:11 +03:00
}
2022-02-23 00:03:14 +03:00
delete opts . frontmatterErrors
Object . assign ( this , { ... opts } )
2020-09-27 15:10:11 +03:00
// 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
2021-01-11 20:30:57 +03:00
this . rawLearningTracks = this . learningTracks
2021-01-29 15:32:31 +03:00
this . rawIncludeGuides = this . includeGuides
2022-04-01 19:01:37 +03:00
this . rawIntroLinks = this . introLinks
2020-09-27 15:10:11 +03:00
2021-04-15 21:16:47 +03:00
// Is this the Homepage or a Product, Category, Topic, or Article?
this . documentType = getDocumentType ( this . relativePath )
2021-04-01 22:29:59 +03:00
// Get array of versions that the page is available in for fast lookup
this . applicableVersions = getApplicableVersions ( this . versions , this . fullPath )
2022-02-24 16:05:35 +03:00
// Only check the parent product ID for English because if a top-level
// product is edited in English, it will fail for translations until
// the next translation pipeline PR gets a chance to catch up.
if ( this . languageCode === 'en' ) {
// 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! `
)
}
2020-10-23 18:13:05 +03:00
}
2020-09-27 15:10:11 +03:00
// derive array of Permalink objects
2021-07-15 00:35:01 +03:00
this . permalinks = Permalink . derive (
this . languageCode ,
this . relativePath ,
this . title ,
this . applicableVersions
)
2020-09-27 15:10:11 +03:00
2021-01-14 18:46:59 +03:00
if ( this . relativePath . endsWith ( 'index.md' ) ) {
// get an array of linked items in product and category TOCs
this . tocItems = getTocItems ( this )
}
2020-09-27 15:10:11 +03:00
// if this is an article and it doesn't have showMiniToc = false, set mini TOC to true
2021-05-20 17:01:33 +03:00
if ( ! this . relativePath . endsWith ( 'index.md' ) ) {
2021-07-15 00:35:01 +03:00
this . showMiniToc = this . showMiniToc === false ? this . showMiniToc : true
2020-09-27 15:10:11 +03:00
}
2021-12-15 23:12:26 +03:00
this . render = this . _render . bind ( this )
2020-11-13 23:49:50 +03:00
2020-09-27 15:10:11 +03:00
return this
}
2021-07-15 00:35:01 +03:00
buildRedirects ( ) {
2022-04-28 00:59:00 +03:00
return generateRedirectsForPermalinks ( this . permalinks , this . redirect _from || [ ] )
2020-12-15 21:40:50 +03:00
}
2020-09-27 15:10:11 +03:00
// Infer the parent product ID from the page's relative file path
2021-07-15 00:35:01 +03:00
get parentProductId ( ) {
2020-09-27 15:10:11 +03:00
// Each page's top-level content directory matches its product ID
2020-09-29 20:36:07 +03:00
const id = this . relativePath . split ( '/' ) [ 0 ]
2020-09-27 15:10:11 +03:00
// ignore top-level content/index.md
if ( id === 'index.md' ) return null
// make sure the ID is valid
2020-10-23 18:13:05 +03:00
if ( process . env . NODE _ENV !== 'test' ) {
2022-02-24 16:38:08 +03:00
assert ( productMapKeysAsSet . has ( id ) , ` page ${ this . fullPath } has an invalid product ID: ${ id } ` )
2020-10-23 18:13:05 +03:00
}
2020-09-27 15:10:11 +03:00
return id
}
2021-07-15 00:35:01 +03:00
get parentProduct ( ) {
2021-04-01 22:29:59 +03:00
return productMap [ this . parentProductId ]
2020-09-27 15:10:11 +03:00
}
2021-08-25 22:31:16 +03:00
async renderTitle ( context , opts = { preferShort : true } ) {
return opts . preferShort && this . shortTitle
2020-09-27 15:10:11 +03:00
? this . renderProp ( 'shortTitle' , context , opts )
: this . renderProp ( 'title' , context , opts )
}
2021-07-15 00:35:01 +03:00
async _render ( context ) {
2021-01-29 23:44:50 +03:00
// use English IDs/anchors for translated headings, so links don't break (see #8572)
if ( this . languageCode !== 'en' ) {
2021-02-12 22:23:40 +03:00
const englishHeadings = getEnglishHeadings ( this , context )
2021-01-29 23:44:50 +03:00
context . englishHeadings = englishHeadings
}
2022-12-06 20:11:41 +03:00
this . intro = await renderContentWithFallback ( this , 'rawIntro' , context )
this . introPlainText = await renderContentWithFallback ( this , 'rawIntro' , context , {
2022-02-03 19:56:05 +03:00
textOnly : true ,
} )
2022-12-06 20:11:41 +03:00
this . title = await renderContentWithFallback ( this , 'rawTitle' , context , {
2021-07-15 00:35:01 +03:00
textOnly : true ,
2022-02-03 19:56:05 +03:00
} )
2022-12-06 20:11:41 +03:00
const html = await renderContentWithFallback ( this , 'markdown' , context )
2020-09-27 15:10:11 +03:00
2021-10-08 01:02:58 +03:00
// Adding communityRedirect for Discussions, Sponsors, and Codespaces - request from Product
2021-10-07 21:58:31 +03:00
if (
this . parentProduct &&
( this . parentProduct . id === 'discussions' ||
this . parentProduct . id === 'sponsors' ||
this . parentProduct . id === 'codespaces' )
) {
2021-10-08 01:02:58 +03:00
this . communityRedirect = {
2021-10-07 21:58:31 +03:00
name : 'Provide GitHub Feedback' ,
2022-07-28 00:36:23 +03:00
href : ` https://github.com/community/community/discussions/categories/ ${ this . parentProduct . id } ` ,
2021-10-07 21:58:31 +03:00
}
}
2020-09-27 15:10:11 +03:00
// product frontmatter may contain liquid
2022-02-07 19:08:51 +03:00
if ( this . rawProduct ) {
2022-12-06 20:11:41 +03:00
this . product = await renderContentWithFallback ( this , 'rawProduct' , context )
2020-09-27 15:10:11 +03:00
}
// permissions frontmatter may contain liquid
2022-02-07 19:08:51 +03:00
if ( this . rawPermissions ) {
2022-12-06 20:11:41 +03:00
this . permissions = await renderContentWithFallback ( this , 'rawPermissions' , context )
2020-09-27 15:10:11 +03:00
}
2021-04-28 00:14:14 +03:00
// Learning tracks may contain Liquid and need to have versioning processed.
2022-02-07 19:08:51 +03:00
if ( this . rawLearningTracks ) {
2021-07-15 00:35:01 +03:00
const { featuredTrack , learningTracks } = await processLearningTracks (
this . rawLearningTracks ,
context
)
2021-04-28 00:14:14 +03:00
this . featuredTrack = featuredTrack
2021-01-11 20:30:57 +03:00
this . learningTracks = learningTracks
}
2022-04-01 19:01:37 +03:00
// introLinks may contain Liquid and need to have versioning processed.
if ( this . rawIntroLinks ) {
const introLinks = { }
for ( const [ rawKey , value ] of Object . entries ( this . rawIntroLinks ) ) {
introLinks [ rawKey ] = await renderContent ( value , context , {
textOnly : true ,
} )
}
this . introLinks = introLinks
}
2021-01-29 15:32:31 +03:00
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 ) {
2021-07-15 00:35:01 +03:00
this . allTopics = union ( this . allTopics , page . topics ) . sort ( ( a , b ) =>
a . localeCompare ( b , page . languageCode )
2021-05-19 20:52:56 +03:00
)
2021-01-29 15:32:31 +03:00
guide . topics = page . topics
}
delete guide . page
return guide
} )
}
2021-11-17 01:39:09 +03:00
// set a flag so layout knows whether to render a mac/windows/linux switcher element
2021-11-30 02:04:06 +03:00
this . detectedPlatforms = [ 'mac' , 'windows' , 'linux' ] . filter (
( platform ) =>
html . includes ( ` extended-markdown ${ platform } ` ) || html . includes ( ` platform- ${ platform } ` )
)
2021-11-17 01:39:09 +03:00
this . includesPlatformSpecificContent = this . detectedPlatforms . length > 0
2021-11-30 02:04:06 +03:00
// set flags for webui, cli, etc switcher element
2022-05-23 17:48:49 +03:00
this . detectedTools = Object . keys ( allTools ) . filter (
( tool ) => html . includes ( ` extended-markdown ${ tool } ` ) || html . includes ( ` tool- ${ tool } ` )
)
2022-05-24 17:40:46 +03:00
// pass the list of all possible tools around to components and utilities that will need it later on
2022-05-23 17:48:49 +03:00
this . allToolsParsed = allTools
2021-11-30 02:04:06 +03:00
this . includesToolSpecificContent = this . detectedTools . length > 0
2021-01-29 23:44:50 +03:00
return html
2020-09-27 15:10:11 +03:00
}
// Allow other modules (like custom liquid tags) to make one-off requests
// for a page's rendered properties like `title` and `intro`
2021-07-15 00:35:01 +03:00
async renderProp ( propName , context , opts = { unwrap : false } ) {
2020-09-27 15:10:11 +03:00
let prop
if ( propName === 'title' ) {
2022-12-06 20:11:41 +03:00
prop = 'rawTitle'
2020-09-27 15:10:11 +03:00
} else if ( propName === 'shortTitle' ) {
2022-12-06 20:11:41 +03:00
prop = this . rawShortTitle ? 'rawShortTitle' : 'rawTitle'
2020-09-27 15:10:11 +03:00
} else if ( propName === 'intro' ) {
2022-12-06 20:11:41 +03:00
prop = 'rawIntro'
2020-09-27 15:10:11 +03:00
} else {
2022-12-06 20:11:41 +03:00
prop = propName
2020-09-27 15:10:11 +03:00
}
2022-12-06 20:11:41 +03:00
const html = await renderContentWithFallback ( this , prop , context , opts )
2020-09-27 15:10:11 +03:00
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
2021-07-15 00:35:01 +03:00
static getHomepage ( requestPath ) {
2020-09-27 15:10:11 +03:00
return requestPath . replace ( /\/articles.*/ , '' )
}
// given a page path, return an array of objects containing hrefs
// for that page in all languages
2021-07-15 00:35:01 +03:00
static getLanguageVariants ( href ) {
2020-09-27 15:10:11 +03:00
const suffix = pathUtils . getPathWithoutLanguage ( href )
2021-07-15 00:35:01 +03:00
return Object . values ( languages ) . map ( ( { name , code , hreflang } ) => {
// eslint-disable-line
2020-09-27 15:10:11 +03:00
return {
name ,
code ,
hreflang ,
2021-07-15 00:35:01 +03:00
href : ` / ${ code } ${ suffix } ` . replace ( patterns . trailingSlash , '$1' ) ,
2020-09-27 15:10:11 +03:00
}
} )
}
}
2021-07-14 23:49:18 +03:00
export default Page