2020-09-27 15:10:11 +03:00
const assert = require ( 'assert' )
const path = require ( 'path' )
const cheerio = require ( 'cheerio' )
const patterns = require ( './patterns' )
const getMapTopicContent = require ( './get-map-topic-content' )
2020-10-23 18:13:05 +03:00
const getApplicableVersions = require ( './get-applicable-versions' )
2020-09-27 15:10:11 +03:00
const generateRedirectsForPermalinks = require ( './redirects/permalinks' )
const getEnglishHeadings = require ( './get-english-headings' )
const getTocItems = require ( './get-toc-items' )
const pathUtils = require ( './path-utils' )
const Permalink = require ( './permalink' )
const languages = require ( './languages' )
const renderContent = require ( './render-content' )
2020-10-16 17:47:06 +03:00
const { renderReact } = require ( './react/engine' )
2020-09-27 15:10:11 +03:00
const products = require ( './all-products' )
const slash = require ( 'slash' )
2020-11-13 23:49:50 +03:00
const statsd = require ( './statsd' )
2021-02-02 21:14:39 +03:00
const readFileContents = require ( './read-file-contents' )
2021-01-11 20:30:57 +03:00
const getLinkData = require ( './get-link-data' )
2021-01-29 15:32:31 +03:00
const union = require ( 'lodash/union' )
2020-09-27 15:10:11 +03:00
class Page {
2021-01-14 18:46:59 +03:00
static async init ( opts ) {
opts = await Page . read ( opts )
2020-12-15 21:56:25 +03:00
if ( ! opts ) return
return new Page ( opts )
}
2021-01-14 18:46:59 +03:00
static async read ( opts ) {
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-02-02 21:14:39 +03:00
const { data , content , errors : frontmatterErrors } = await readFileContents ( fullPath , opts . languageCode )
2021-01-14 18:46:59 +03:00
return {
... opts ,
relativePath ,
fullPath ,
... data ,
markdown : content ,
frontmatterErrors
}
2020-12-09 20:40:58 +03:00
} catch ( err ) {
if ( err . code === 'ENOENT' ) return false
console . error ( err )
}
}
constructor ( opts ) {
2020-09-27 15:10:11 +03:00
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
2021-01-11 20:30:57 +03:00
this . rawLearningTracks = this . learningTracks
2021-01-29 15:32:31 +03:00
this . rawIncludeGuides = this . includeGuides
2020-09-27 15:10:11 +03:00
2020-10-23 18:13:05 +03:00
// a page should only be available in versions that its parent product is available in
const versionsParentProductIsNotAvailableIn = getApplicableVersions ( this . versions , this . fullPath )
// only the homepage will not have this.parentProduct
. filter ( availableVersion => this . parentProduct && ! this . parentProduct . versions . includes ( availableVersion ) )
2020-12-03 01:07:56 +03:00
if ( versionsParentProductIsNotAvailableIn . length ) {
2020-10-23 18:13:05 +03:00
throw new Error ( ` \` versions \` frontmatter in ${ this . fullPath } contains ${ versionsParentProductIsNotAvailableIn } , which ${ this . parentProduct . id } product is not available in! ` )
}
2020-09-27 15:10:11 +03:00
// derive array of Permalink objects
2020-09-29 20:36:07 +03:00
this . permalinks = Permalink . derive ( this . languageCode , this . relativePath , this . title , this . versions )
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
if ( ! this . relativePath . endsWith ( 'index.md' ) && ! this . mapTopic ) {
this . showMiniToc = this . showMiniToc === false
? this . showMiniToc
: true
}
2020-11-13 23:49:50 +03:00
// Instrument the `_render` method, so externally we call #render
// but it's wrapped in a timer that reports to Datadog
this . render = statsd . asyncTimer ( this . _render . bind ( this ) , 'page.render' )
2020-09-27 15:10:11 +03:00
return this
}
2020-12-15 21:40:50 +03:00
buildRedirects ( ) {
// create backwards-compatible old paths for page permalinks and frontmatter redirects
this . redirects = generateRedirectsForPermalinks ( this . permalinks , this . redirect _from )
return this . redirects
}
2020-09-27 15:10:11 +03:00
// 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
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' ) {
assert (
Object . keys ( products ) . includes ( id ) ,
` page ${ this . fullPath } has an invalid product ID: ${ id } `
)
}
2020-09-27 15:10:11 +03:00
return id
}
get parentProduct ( ) {
return products [ this . parentProductId ]
}
async renderTitle ( context , opts = { } ) {
return this . shortTitle
? this . renderProp ( 'shortTitle' , context , opts )
: this . renderProp ( 'title' , context , opts )
}
2020-11-13 23:49:50 +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' ) {
const englishHeadings = getEnglishHeadings ( this , context . pages )
context . englishHeadings = englishHeadings
}
2020-09-27 15:10:11 +03:00
this . intro = await renderContent ( this . rawIntro , context )
this . introPlainText = await renderContent ( this . rawIntro , context , { textOnly : true } )
this . title = await renderContent ( this . rawTitle , context , { textOnly : true , encodeEntities : true } )
this . shortTitle = await renderContent ( this . shortTitle , context , { textOnly : true , encodeEntities : true } )
2020-10-16 17:47:06 +03:00
let markdown = this . mapTopic
2020-12-15 04:25:01 +03:00
// get the map topic child articles from the siteTree
2020-12-15 19:52:53 +03:00
? getMapTopicContent ( this . parentProduct . id , context . siteTree , context . currentLanguage , context . currentVersion , context . currentPath )
2020-09-27 15:10:11 +03:00
: this . markdown
2020-12-03 01:15:18 +03:00
2020-10-16 17:47:06 +03:00
// If the article is interactive parse the React!
if ( this . interactive ) {
// Search for the react code comments to find the react components
2020-10-20 16:06:43 +03:00
const reactComponents = markdown . match ( / < ! - - r e a c t - - > ( . * ? ) < ! - - e n d - r e a c t - - > / g s )
2020-10-16 17:47:06 +03:00
// Render each of the react components in the markdown
2020-10-20 16:00:22 +03:00
await Promise . all ( reactComponents . map ( async ( reactComponent ) => {
let componentStr = reactComponent
2020-10-16 17:47:06 +03:00
// Remove the React comment indicators
componentStr = componentStr . replace ( '<!--react-->\n' , '' ) . replace ( '<!--react-->' , '' )
componentStr = componentStr . replace ( '\n<!--end-react-->' , '' ) . replace ( '<!--end-react-->' , '' )
// Get the rendered component
const renderedComponent = await renderReact ( componentStr )
// Replace the react component with the rendered markdown
2020-10-20 16:00:22 +03:00
markdown = markdown . replace ( reactComponent , renderedComponent )
} ) )
2020-10-16 17:47:06 +03:00
}
2020-09-27 15:10:11 +03:00
2021-01-29 23:44:50 +03:00
context . relativePath = this . relativePath
2020-09-27 15:10:11 +03:00
const html = await renderContent ( markdown , context )
// product frontmatter may contain liquid
if ( this . product ) {
this . product = await renderContent ( this . rawProduct , context )
}
// permissions frontmatter may contain liquid
if ( this . permissions ) {
this . permissions = await renderContent ( this . rawPermissions , context )
}
2021-01-11 20:30:57 +03:00
if ( this . learningTracks ) {
const learningTracks = [ ]
for await ( const trackName of this . rawLearningTracks ) {
const track = context . site . data [ 'learning-tracks' ] [ context . currentProduct ] [ trackName ]
if ( ! track ) continue
learningTracks . push ( {
2021-01-25 19:57:32 +03:00
trackName ,
2021-01-11 20:30:57 +03:00
title : await renderContent ( track . title , context , { textOnly : true , encodeEntities : true } ) ,
description : await renderContent ( track . description , context , { textOnly : true , encodeEntities : true } ) ,
2021-01-18 15:23:23 +03:00
guides : await getLinkData ( track . guides , context )
2021-01-11 20:30:57 +03:00
} )
}
this . learningTracks = learningTracks
}
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 ) {
this . allTopics = union ( this . allTopics , page . topics )
guide . topics = page . topics
}
delete guide . page
return guide
} )
}
2020-09-27 15:10:11 +03:00
// set a flag so layout knows whether to render a mac/windows/linux switcher element
2021-01-29 23:44:50 +03:00
this . includesPlatformSpecificContent = (
html . includes ( 'extended-markdown mac' ) ||
html . includes ( 'extended-markdown windows' ) ||
html . includes ( 'extended-markdown linux' )
)
2020-09-27 15:10:11 +03:00
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`
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' )
}
} )
}
}
module . exports = Page