diff --git a/Dockerfile b/Dockerfile
index c360b4e2abd3..26217363aca5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -52,6 +52,9 @@ COPY tsconfig.json ./tsconfig.json
RUN npx tsc
+# We need to copy data in order to do the build
+COPY --chown=node:node data ./data
+
RUN npm run build
# --------------------------------------------------------------------------------
@@ -85,7 +88,6 @@ ENV AIRGAP true
# Copy only what's needed to run the server
COPY --chown=node:node assets ./assets
COPY --chown=node:node content ./content
-COPY --chown=node:node data ./data
COPY --chown=node:node includes ./includes
COPY --chown=node:node layouts ./layouts
COPY --chown=node:node lib ./lib
diff --git a/content/actions/guides/installing-an-apple-certificate-on-macos-runners-for-xcode-development.md b/content/actions/guides/installing-an-apple-certificate-on-macos-runners-for-xcode-development.md
index 64ba326ff72a..988e187c5bc7 100644
--- a/content/actions/guides/installing-an-apple-certificate-on-macos-runners-for-xcode-development.md
+++ b/content/actions/guides/installing-an-apple-certificate-on-macos-runners-for-xcode-development.md
@@ -100,12 +100,12 @@ jobs:
echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode --output $PP_PATH
# create temporary keychain
- security create-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN_PATH
+ security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
- security unlock-keychain -p $KEYCHAIN_PASSWORD $KEYCHAIN_PATH
+ security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
- security import $CERTIFICATE_PATH -P $P12_PASSWORD -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
+ security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
# apply provisioning profile
diff --git a/content/actions/managing-workflow-runs/using-the-visualization-graph.md b/content/actions/managing-workflow-runs/using-the-visualization-graph.md
index 2fbeb2a9ae2a..df1a4ef56653 100644
--- a/content/actions/managing-workflow-runs/using-the-visualization-graph.md
+++ b/content/actions/managing-workflow-runs/using-the-visualization-graph.md
@@ -9,7 +9,6 @@ versions:
---
{% data reusables.actions.enterprise-beta %}
-{% data reusables.actions.visualization-beta %}
{% data reusables.actions.enterprise-github-hosted-runners %}
{% data reusables.actions.ae-beta %}
diff --git a/data/features/README.md b/data/features/README.md
new file mode 100644
index 000000000000..c2fca9380d58
--- /dev/null
+++ b/data/features/README.md
@@ -0,0 +1,52 @@
+## Feature-based versioning
+
+Feature-based versioning allows us to define and control the versions of an arbitrarily named "feature" in one place.
+
+**Note**: Do not delete `data/features/placeholder.yml` because it is used by tests.
+
+## How it works
+
+Add a new YAML file with the feature name you want to use in this directory. For a feature named `meow`, that would be `data/features/meow.yml`.
+
+Add a `versions` block to the YML file with the short names of the versions the feature is available in. For example:
+
+```yaml
+versions:
+ fpt: '*'
+ ghes: '>3.1'
+ ghae: '*'
+```
+
+The format and allowed values are the same as the [frontmatter versions property](/content#versions).
+
+### Liquid conditionals
+
+Now you can use `{% if meow %} ... {% endif %}` in content files! Note this is the `if` tag, not the new `ifversion` tag.
+
+### Frontmatter
+
+You can also use the feature in frontmatter in content files:
+
+```yaml
+versions:
+ fpt: '*'
+ ghes: '>3.1'
+ feature: 'meow'
+```
+
+If you want a content file to apply to more than one feature, you can do this:
+
+```yaml
+versions:
+ fpt: '*'
+ ghes: '>3.1'
+ feature: ['meow', 'blorp']
+```
+
+## Schema enforcement
+
+The schema for validating the feature versioning lives in [`tests/helpers/schemas/feature-versions.js`](tests/helpers/schemas/feature-versions.js) and is exercised by [`tests/content/lint-files.js`](tests/content/lint-files.js).
+
+## Script to remove feature tags
+
+TBD!
diff --git a/data/features/placeholder.yml b/data/features/placeholder.yml
new file mode 100644
index 000000000000..a133861e3570
--- /dev/null
+++ b/data/features/placeholder.yml
@@ -0,0 +1,4 @@
+# Do not delete! Used by tests.
+versions:
+ ghes: '>3.0'
+ ghae: '*'
diff --git a/data/reusables/actions/visualization-beta.md b/data/reusables/actions/visualization-beta.md
deleted file mode 100644
index da3ac2b0d2b4..000000000000
--- a/data/reusables/actions/visualization-beta.md
+++ /dev/null
@@ -1,7 +0,0 @@
-{% if currentVersion == "free-pro-team@latest" or currentVersion ver_gt "enterprise-server@3.0" or currentVersion == "github-ae@latest" %}
-{% note %}
-
-**Note:** The workflow visualization graph for {% data variables.product.prodname_actions %} is currently in beta and subject to change.
-
-{% endnote %}
-{% endif %}
diff --git a/data/reusables/command_line/providing-token-as-password.md b/data/reusables/command_line/providing-token-as-password.md
index 54cb54e48e65..a938fcfd9ca3 100644
--- a/data/reusables/command_line/providing-token-as-password.md
+++ b/data/reusables/command_line/providing-token-as-password.md
@@ -4,6 +4,6 @@ For example, on the command line you would enter the following:
```shell
$ git clone https://{% data variables.command_line.codeblock %}/username/repo.git
-Username: your_username
+Username: your_username
Password: your_token
```
diff --git a/lib/feature-flags.js b/lib/feature-flags.js
index 970599f0733b..f125b40b8cfe 100644
--- a/lib/feature-flags.js
+++ b/lib/feature-flags.js
@@ -1,4 +1,5 @@
-const featureFlags = require('../feature-flags')
+const readJsonFile = require('./read-json-file')
+const featureFlags = readJsonFile('./feature-flags.json')
// add feature flags as environment variables
Object.entries(featureFlags).forEach(([feature, value]) => {
diff --git a/lib/frontmatter.js b/lib/frontmatter.js
index 3fb6fafe8545..b93f40608e8e 100644
--- a/lib/frontmatter.js
+++ b/lib/frontmatter.js
@@ -1,3 +1,5 @@
+const fs = require('fs')
+const path = require('path')
const parse = require('./read-frontmatter')
const semver = require('semver')
const layouts = require('./layouts')
@@ -9,8 +11,10 @@ const semverRange = {
conform: semverValidRange,
message: 'Must be a valid SemVer range'
}
-const versionIds = Object.keys(require('./all-versions'))
+const versionObjs = Object.values(require('./all-versions'))
const guideTypes = ['overview', 'quick_start', 'tutorial', 'how_to', 'reference']
+const featureVersions = fs.readdirSync(path.posix.join(process.cwd(), 'data/features'))
+ .map(file => path.basename(file, '.yml'))
const schema = {
properties: {
@@ -197,15 +201,31 @@ const schema = {
}
}
+const featureVersionsProp = {
+ feature: {
+ type: ['string', 'array'],
+ enum: featureVersions,
+ items: {
+ type: 'string'
+ },
+ message: 'must be the name (or names) of a feature that matches "filename" in data/features/_filename_.yml'
+ }
+}
+
schema.properties.versions = {
type: ['object', 'string'], // allow a '*' string to indicate all versions
required: true,
- properties: versionIds.reduce((acc, versionId) => {
- acc[versionId] = semverRange
+ properties: versionObjs.reduce((acc, versionObj) => {
+ acc[versionObj.plan] = semverRange
+ acc[versionObj.shortName] = semverRange
return acc
- }, {})
+ }, featureVersionsProp)
}
+// Support 'github-ae': next
+schema.properties.versions.properties['github-ae'] = 'next'
+schema.properties.versions.properties.ghae = 'next'
+
function frontmatter (markdown, opts = {}) {
const defaults = {
schema,
diff --git a/lib/get-applicable-versions.js b/lib/get-applicable-versions.js
index 5ac635e5019a..d7adb2408075 100644
--- a/lib/get-applicable-versions.js
+++ b/lib/get-applicable-versions.js
@@ -1,6 +1,17 @@
+const path = require('path')
+const { reduce, sortBy } = require('lodash')
const allVersions = require('./all-versions')
const versionSatisfiesRange = require('./version-satisfies-range')
const checkIfNextVersionOnly = require('./check-if-next-version-only')
+const dataDirectory = require('./data-directory')
+const encodeBracketedParentheses = require('./encode-bracketed-parentheses')
+const featuresDir = path.posix.join(__dirname, '../data/features')
+
+const featureData = dataDirectory(featuresDir, {
+ preprocess: dataString =>
+ encodeBracketedParentheses(dataString.trimEnd()),
+ ignorePatterns: [/README\.md$/]
+})
// return an array of versions that an article's product versions encompasses
function getApplicableVersions (frontmatterVersions, filepath) {
@@ -13,17 +24,63 @@ function getApplicableVersions (frontmatterVersions, filepath) {
return Object.keys(allVersions)
}
- // get an array like: [ 'free-pro-team@latest', 'enterprise-server@2.21', 'enterprise-cloud@latest' ]
- const applicableVersions = []
+ // Check for frontmatter that includes a feature name, like:
+ // fpt: '*'
+ // feature: 'foo'
+ // or multiple feature names, like:
+ // fpt: '*'
+ // feature: ['foo', 'bar']
+ // and add the versions affiliated with the feature (e.g., foo) to the frontmatter versions object:
+ // fpt: '*'
+ // ghes: '>=2.23'
+ // ghae: '*'
+ // where the feature is bringing the ghes and ghae versions into the mix.
+ const featureVersions = reduce(frontmatterVersions, (result, value, key) => {
+ if (key === 'feature') {
+ if (typeof value === 'string') {
+ Object.assign(result, { ...featureData[value].versions })
+ } else if (Array.isArray(value)) {
+ value.forEach(str => {
+ Object.assign(result, { ...featureData[str].versions })
+ })
+ }
+ delete result[key]
+ }
+ return result
+ }, {})
+
+ // We will be evaluating feature versions separately, so we can remove this.
+ delete frontmatterVersions.feature
+
+ // Get available versions for frontmatter and for feature versions.
+ const foundFeatureVersions = evaluateVersions(featureVersions)
+ const foundFrontmatterVersions = evaluateVersions(frontmatterVersions)
+
+ // Combine them!
+ const applicableVersions = [...new Set(foundFrontmatterVersions.versions.concat(foundFeatureVersions.versions))]
+
+ if (!applicableVersions.length && !foundFrontmatterVersions.isNextVersionOnly && !foundFeatureVersions.isNextVersionOnly) {
+ throw new Error(`No applicable versions found for ${filepath}. Please double-check the page's \`versions\` frontmatter.`)
+ }
+ // Sort them by the order in lib/all-versions.
+ const sortedVersions = sortBy(applicableVersions, (v) => { return Object.keys(allVersions).indexOf(v) })
+
+ return sortedVersions
+}
+
+function evaluateVersions (versionsObj) {
let isNextVersionOnly = false
- // where frontmatter is something like:
+ // get an array like: [ 'free-pro-team@latest', 'enterprise-server@2.21', 'enterprise-cloud@latest' ]
+ const versions = []
+
+ // where versions obj is something like:
// fpt: '*'
// ghes: '>=2.19'
// ghae: '*'
// ^ where each key corresponds to a plan's short name (defined in lib/all-versions.js)
- Object.entries(frontmatterVersions)
+ Object.entries(versionsObj)
.forEach(([plan, planValue]) => {
// Special handling for frontmatter that evalues to the next GHES release number or a hardcoded `next`.
isNextVersionOnly = checkIfNextVersionOnly(planValue)
@@ -37,16 +94,12 @@ function getApplicableVersions (frontmatterVersions, filepath) {
const versionToCompare = relevantVersion.hasNumberedReleases ? relevantVersion.currentRelease : '1.0'
if (versionSatisfiesRange(versionToCompare, planValue)) {
- applicableVersions.push(relevantVersion.version)
+ versions.push(relevantVersion.version)
}
})
})
- if (!applicableVersions.length && !isNextVersionOnly) {
- throw new Error(`No applicable versions found for ${filepath}. Please double-check the page's \`versions\` frontmatter.`)
- }
-
- return applicableVersions
+ return { versions, isNextVersionOnly }
}
module.exports = getApplicableVersions
diff --git a/lib/read-json-file.js b/lib/read-json-file.js
new file mode 100644
index 000000000000..353bdd078bb4
--- /dev/null
+++ b/lib/read-json-file.js
@@ -0,0 +1,14 @@
+const fs = require('fs')
+const path = require('path')
+
+module.exports = function readJsonFile (xpath) {
+ return JSON.parse(
+ fs.readFileSync(
+ path.join(
+ process.cwd(),
+ xpath
+ ),
+ 'utf8'
+ )
+ )
+}
diff --git a/lib/redirects/precompile.js b/lib/redirects/precompile.js
index 9112bb32f427..cd3b4dfa7587 100755
--- a/lib/redirects/precompile.js
+++ b/lib/redirects/precompile.js
@@ -1,4 +1,5 @@
-const developerRedirects = require('../redirects/static/developer')
+const readJsonFile = require('../read-json-file')
+const developerRedirects = readJsonFile('./lib/redirects/static/developer.json')
const { latest } = require('../../lib/enterprise-server-releases')
const latestDevRedirects = {}
diff --git a/lib/render-content/plugins/rewrite-local-links.js b/lib/render-content/plugins/rewrite-local-links.js
index 5e121c333f16..a357a312edc5 100644
--- a/lib/render-content/plugins/rewrite-local-links.js
+++ b/lib/render-content/plugins/rewrite-local-links.js
@@ -1,15 +1,17 @@
const path = require('path')
const visit = require('unist-util-visit')
-const externalRedirects = Object.keys(require('../../redirects/external-sites'))
const { getPathWithoutLanguage, getVersionStringFromPath } = require('../../path-utils')
const { getNewVersionedPath } = require('../../old-versions-utils')
const patterns = require('../../patterns')
const { deprecated, latest } = require('../../enterprise-server-releases')
const nonEnterpriseDefaultVersion = require('../../non-enterprise-default-version')
const allVersions = require('../../all-versions')
+const removeFPTFromPath = require('../../remove-fpt-from-path')
const supportedVersions = Object.keys(allVersions)
const supportedPlans = Object.values(allVersions).map(v => v.plan)
-const removeFPTFromPath = require('../../remove-fpt-from-path')
+const readJsonFile = require('../../read-json-file')
+const externalRedirects = Object.keys(readJsonFile('./lib/redirects/external-sites.json'))
+
// Matches any tags with an href that starts with `/`
const matcher = node => (
diff --git a/lib/rewrite-local-links.js b/lib/rewrite-local-links.js
index 947c6846841e..d30101f28cd6 100644
--- a/lib/rewrite-local-links.js
+++ b/lib/rewrite-local-links.js
@@ -1,6 +1,5 @@
const assert = require('assert')
const path = require('path')
-const externalRedirects = Object.keys(require('./redirects/external-sites'))
const { getPathWithoutLanguage, getVersionStringFromPath } = require('./path-utils')
const { getNewVersionedPath } = require('./old-versions-utils')
const patterns = require('./patterns')
@@ -10,6 +9,8 @@ const allVersions = require('./all-versions')
const supportedVersions = Object.keys(allVersions)
const supportedPlans = Object.values(allVersions).map(v => v.plan)
const removeFPTFromPath = require('./remove-fpt-from-path')
+const readJsonFile = require('./read-json-file')
+const externalRedirects = readJsonFile('./lib/redirects/external-sites.json')
// Content authors write links like `/some/article/path`, but they need to be
// rewritten on the fly to match the current language and page version
diff --git a/lib/search/algolia-search.js b/lib/search/algolia-search.js
index 4f415a9c5c09..4b915a52b9fa 100644
--- a/lib/search/algolia-search.js
+++ b/lib/search/algolia-search.js
@@ -1,6 +1,6 @@
const algoliasearch = require('algoliasearch')
const { get } = require('lodash')
-const { namePrefix } = require('./config')
+const { namePrefix } = require('./config.js')
// https://www.algolia.com/apps/ZI5KPY1HBE/dashboard
// This API key is public. There's also a private API key for writing to the Algolia API
diff --git a/lib/search/lunr-search.js b/lib/search/lunr-search.js
index 5d01d60faab8..8fddc5ff35d2 100644
--- a/lib/search/lunr-search.js
+++ b/lib/search/lunr-search.js
@@ -8,7 +8,7 @@ require('lunr-languages/lunr.pt')(lunr)
require('lunr-languages/lunr.de')(lunr)
const { get } = require('lodash')
const readFileAsync = require('../readfile-async')
-const { namePrefix } = require('./config')
+const { namePrefix } = require('./config.js')
const { decompress } = require('./compress')
const LUNR_DIR = './indexes'
diff --git a/middleware/archived-enterprise-versions.js b/middleware/archived-enterprise-versions.js
index 3a5cd96f7fcc..c599a49db1df 100644
--- a/middleware/archived-enterprise-versions.js
+++ b/middleware/archived-enterprise-versions.js
@@ -5,8 +5,9 @@ const patterns = require('../lib/patterns')
const versionSatisfiesRange = require('../lib/version-satisfies-range')
const isArchivedVersion = require('../lib/is-archived-version')
const got = require('got')
-const archvivedRedirects = require('../lib/redirects/static/archived-redirects-from-213-to-217')
-const archivedFrontmatterFallbacks = require('../lib/redirects/static/archived-frontmatter-fallbacks')
+const readJsonFile = require('../lib/read-json-file')
+const archvivedRedirects = readJsonFile('./lib/redirects/static/archived-redirects-from-213-to-217.json')
+const archivedFrontmatterFallbacks = readJsonFile('./lib/redirects/static/archived-frontmatter-fallbacks.json')
// This module handles requests for deprecated GitHub Enterprise versions
// by routing them to static content in help-docs-archived-enterprise-versions
diff --git a/middleware/context.js b/middleware/context.js
index 42cfcfe168ed..9eedd09a98d5 100644
--- a/middleware/context.js
+++ b/middleware/context.js
@@ -11,7 +11,8 @@ const {
} = require('../lib/path-utils')
const productNames = require('../lib/product-names')
const warmServer = require('../lib/warm-server')
-const featureFlags = Object.keys(require('../feature-flags'))
+const readJsonFile = require('../lib/read-json-file')
+const featureFlags = Object.keys(readJsonFile('./feature-flags.json'))
const builtAssets = require('../lib/built-asset-urls')
const searchVersions = require('../lib/search/versions')
const nonEnterpriseDefaultVersion = require('../lib/non-enterprise-default-version')
diff --git a/middleware/contextualizers/features.js b/middleware/contextualizers/features.js
new file mode 100644
index 000000000000..89e5dfb84cc8
--- /dev/null
+++ b/middleware/contextualizers/features.js
@@ -0,0 +1,18 @@
+const getApplicableVersions = require('../../lib/get-applicable-versions')
+
+module.exports = async function features (req, res, next) {
+ if (!req.context.page) return next()
+
+ // Determine whether the currentVersion belongs to the list of versions the feature is available in.
+ Object.keys(req.context.site.data.features).forEach(featureName => {
+ const { versions } = req.context.site.data.features[featureName]
+ const applicableVersions = getApplicableVersions(versions, req.path)
+
+ // Adding the resulting boolean to the context object gives us the ability to use
+ // `{% if featureName ... %}` conditionals in content files.
+ const isFeatureAvailableInCurrentVersion = applicableVersions.includes(req.context.currentVersion)
+ req.context[featureName] = isFeatureAvailableInCurrentVersion
+ })
+
+ return next()
+}
diff --git a/middleware/contextualizers/graphql.js b/middleware/contextualizers/graphql.js
index f07a1506ed9e..037f4c0a3b00 100644
--- a/middleware/contextualizers/graphql.js
+++ b/middleware/contextualizers/graphql.js
@@ -1,10 +1,11 @@
const fs = require('fs')
const path = require('path')
-const previews = require('../../lib/graphql/static/previews')
-const upcomingChanges = require('../../lib/graphql/static/upcoming-changes')
-const changelog = require('../../lib/graphql/static/changelog')
-const prerenderedObjects = require('../../lib/graphql/static/prerendered-objects')
-const prerenderedInputObjects = require('../../lib/graphql/static/prerendered-input-objects')
+const readJsonFile = require('../../lib/read-json-file')
+const previews = readJsonFile('./lib/graphql/static/previews.json')
+const upcomingChanges = readJsonFile('./lib/graphql/static/upcoming-changes.json')
+const changelog = readJsonFile('./lib/graphql/static/changelog.json')
+const prerenderedObjects = readJsonFile('./lib/graphql/static/prerendered-objects.json')
+const prerenderedInputObjects = readJsonFile('./lib/graphql/static/prerendered-input-objects.json')
const allVersions = require('../../lib/all-versions')
const explorerUrl = process.env.NODE_ENV === 'production'
diff --git a/middleware/index.js b/middleware/index.js
index 32d875949b65..e6198c1826fa 100644
--- a/middleware/index.js
+++ b/middleware/index.js
@@ -51,6 +51,7 @@ const currentProductTree = require('./contextualizers/current-product-tree')
const genericToc = require('./contextualizers/generic-toc')
const breadcrumbs = require('./contextualizers/breadcrumbs')
const earlyAccessBreadcrumbs = require('./contextualizers/early-access-breadcrumbs')
+const features = require('./contextualizers/features')
const productExamples = require('./contextualizers/product-examples')
const devToc = require('./dev-toc')
const featuredLinks = require('./featured-links')
@@ -180,6 +181,7 @@ module.exports = function (app) {
app.use(asyncMiddleware(instrument(genericToc, './contextualizers/generic-toc')))
app.use(asyncMiddleware(instrument(breadcrumbs, './contextualizers/breadcrumbs')))
app.use(asyncMiddleware(instrument(earlyAccessBreadcrumbs, './contextualizers/early-access-breadcrumbs')))
+ app.use(asyncMiddleware(instrument(features, './contextualizers/features')))
app.use(asyncMiddleware(instrument(productExamples, './contextualizers/product-examples')))
app.use(asyncMiddleware(instrument(devToc, './dev-toc')))
diff --git a/middleware/redirects/external.js b/middleware/redirects/external.js
index b6172696e3a1..71c8344ed23c 100644
--- a/middleware/redirects/external.js
+++ b/middleware/redirects/external.js
@@ -1,4 +1,5 @@
-const externalSites = require('../../lib/redirects/external-sites')
+const readJsonFile = require('../../lib/read-json-file')
+const externalSites = readJsonFile('./lib/redirects/external-sites.json')
// blanket redirects to external websites
module.exports = function externalRedirects (req, res, next) {
diff --git a/script/graphql/update-files.js b/script/graphql/update-files.js
index 50da5fbbf783..408b45893b7e 100755
--- a/script/graphql/update-files.js
+++ b/script/graphql/update-files.js
@@ -8,7 +8,7 @@ const { execSync } = require('child_process')
const graphqlDataDir = path.join(process.cwd(), 'data/graphql')
const graphqlStaticDir = path.join(process.cwd(), 'lib/graphql/static')
const { getContents, listMatchingRefs } = require('../helpers/git-utils')
-const dataFilenames = require('./utils/data-filenames')
+const dataFilenames = JSON.parse(fs.readFileSync('./utils/data-filenames.json'))
const allVersions = require('../../lib/all-versions')
const processPreviews = require('./utils/process-previews')
const processUpcomingChanges = require('./utils/process-upcoming-changes')
diff --git a/script/graphql/utils/process-schemas.js b/script/graphql/utils/process-schemas.js
index 16fde19ff2c0..ad55211c6acf 100755
--- a/script/graphql/utils/process-schemas.js
+++ b/script/graphql/utils/process-schemas.js
@@ -1,8 +1,9 @@
const { sortBy } = require('lodash')
const { parse, buildASTSchema } = require('graphql')
const helpers = require('./schema-helpers')
+const fs = require('fs')
-const externalScalars = require('../../../lib/graphql/non-schema-scalars')
+const externalScalars = JSON.parse(fs.readFileSync('../../../lib/graphql/non-schema-scalars.json'))
.map(scalar => {
scalar.id = helpers.getId(scalar.name)
scalar.href = helpers.getFullLink('scalars', scalar.id)
diff --git a/script/graphql/utils/schema-helpers.js b/script/graphql/utils/schema-helpers.js
index 80ed730b7d4c..21958e36014f 100644
--- a/script/graphql/utils/schema-helpers.js
+++ b/script/graphql/utils/schema-helpers.js
@@ -1,5 +1,6 @@
const renderContent = require('../../../lib/render-content')
-const graphqlTypes = require('../../../lib/graphql/types')
+const fs = require('fs')
+const graphqlTypes = JSON.parse(fs.readFileSync('../../../lib/graphql/types.json'))
const {
isScalarType,
isObjectType,
diff --git a/tests/browser/browser.js b/tests/browser/browser.js
index 88138c7ed564..6c1b12e8090d 100644
--- a/tests/browser/browser.js
+++ b/tests/browser/browser.js
@@ -4,7 +4,7 @@ const path = require('path')
const sleep = require('await-sleep')
const { latest } = require('../../lib/enterprise-server-releases')
const languages = require('../../lib/languages')
-const featureFlags = JSON.parse(fs.readFileSync(path.join(process.cwd(), '/feature-flags.json')))
+const featureFlags = JSON.parse(fs.readFileSync(path.join(process.cwd(), './feature-flags.json')))
describe('homepage', () => {
jest.setTimeout(60 * 1000)
diff --git a/tests/content/graphql.js b/tests/content/graphql.js
index 67d2735f6b8a..3f0a3216a848 100644
--- a/tests/content/graphql.js
+++ b/tests/content/graphql.js
@@ -1,13 +1,14 @@
const fs = require('fs')
const path = require('path')
-const previewsJson = require('../../lib/graphql/static/previews')
-const upcomingChangesJson = require('../../lib/graphql/static/upcoming-changes')
-const prerenderedObjectsJson = require('../../lib/graphql/static/prerendered-objects')
+const readJsonFile = require('../../lib/read-json-file')
+const previewsJson = readJsonFile('./lib/graphql/static/previews.json')
+const upcomingChangesJson = readJsonFile('./lib/graphql/static/upcoming-changes.json')
+const prerenderedObjectsJson = readJsonFile('./lib/graphql/static/prerendered-objects.json')
const { schemaValidator, previewsValidator, upcomingChangesValidator } = require('../../lib/graphql/validator')
const revalidator = require('revalidator')
const allVersions = Object.values(require('../../lib/all-versions'))
const graphqlVersions = allVersions.map(v => v.miscVersionName)
-const graphqlTypes = require('../../lib/graphql/types').map(t => t.kind)
+const graphqlTypes = readJsonFile('./lib/graphql/types.json').map(t => t.kind)
describe('graphql json files', () => {
jest.setTimeout(3 * 60 * 1000)
diff --git a/tests/fixtures/feature-versions-frontmatter.md b/tests/fixtures/feature-versions-frontmatter.md
new file mode 100644
index 000000000000..3b34a276c3e7
--- /dev/null
+++ b/tests/fixtures/feature-versions-frontmatter.md
@@ -0,0 +1,7 @@
+---
+title: Some article only versioned for FPT
+versions:
+ fpt: '*'
+ ghes: '>2.21'
+ feature: 'placeholder'
+---
diff --git a/tests/graphql/build-changelog-test.js b/tests/graphql/build-changelog-test.js
index e27f2f86a839..7c04d2edf5ec 100644
--- a/tests/graphql/build-changelog-test.js
+++ b/tests/graphql/build-changelog-test.js
@@ -3,8 +3,9 @@ const { createChangelogEntry, cleanPreviewTitle, previewAnchor, prependDatedEntr
const fs = require('fs').promises
const MockDate = require('mockdate')
const readFileAsync = require('../../lib/readfile-async')
-const expectedChangelogEntry = require('../fixtures/changelog-entry')
-const expectedUpdatedChangelogFile = require('../fixtures/updated-changelog-file')
+const readJsonFile = require('../../lib/read-json-file')
+const expectedChangelogEntry = readJsonFile('./tests/fixtures/changelog-entry.json')
+const expectedUpdatedChangelogFile = readJsonFile('./tests/fixtures/updated-changelog-file.json')
describe('creating a changelog from old schema and new schema', () => {
afterEach(() => {
diff --git a/tests/helpers/schemas/feature-versions-schema.js b/tests/helpers/schemas/feature-versions-schema.js
new file mode 100644
index 000000000000..15b0e662e847
--- /dev/null
+++ b/tests/helpers/schemas/feature-versions-schema.js
@@ -0,0 +1,18 @@
+const { schema } = require('../../../lib/frontmatter')
+
+// Copy the properties from the frontmatter schema.
+const featureVersions = {
+ properties: {
+ versions: Object.assign({}, schema.properties.versions)
+ }
+}
+
+// Remove the feature versions properties.
+// We don't want to allow features within features! We just want pure versioning.
+delete featureVersions.properties.versions.properties.feature
+
+// Call it invalid if any properties other than version properties are found.
+featureVersions.additionalProperties = false
+featureVersions.properties.versions.additionalProperties = false
+
+module.exports = featureVersions
diff --git a/tests/linting/lint-files.js b/tests/linting/lint-files.js
index 0b0497fe6f4b..c8ab4a743554 100644
--- a/tests/linting/lint-files.js
+++ b/tests/linting/lint-files.js
@@ -13,6 +13,7 @@ const { tags } = require('../../lib/liquid-tags/extended-markdown')
const ghesReleaseNotesSchema = require('../helpers/schemas/ghes-release-notes-schema')
const ghaeReleaseNotesSchema = require('../helpers/schemas/ghae-release-notes-schema')
const learningTracksSchema = require('../helpers/schemas/learning-tracks-schema')
+const featureVersionsSchema = require('../helpers/schemas/feature-versions-schema')
const renderContent = require('../../lib/render-content')
const getApplicableVersions = require('../../lib/get-applicable-versions')
const { execSync } = require('child_process')
@@ -31,6 +32,7 @@ const glossariesDir = path.join(rootDir, 'data/glossaries')
const ghesReleaseNotesDir = path.join(rootDir, 'data/release-notes/enterprise-server')
const ghaeReleaseNotesDir = path.join(rootDir, 'data/release-notes/github-ae')
const learningTracks = path.join(rootDir, 'data/learning-tracks')
+const featureVersionsDir = path.join(rootDir, 'data/features')
const languageCodes = Object.keys(languages)
@@ -186,7 +188,7 @@ const yamlWalkOptions = {
}
// different lint rules apply to different content types
-let mdToLint, ymlToLint, ghesReleaseNotesToLint, ghaeReleaseNotesToLint, learningTracksToLint
+let mdToLint, ymlToLint, ghesReleaseNotesToLint, ghaeReleaseNotesToLint, learningTracksToLint, featureVersionsToLint
if (!process.env.TEST_TRANSLATION) {
// compile lists of all the files we want to lint
@@ -227,6 +229,11 @@ if (!process.env.TEST_TRANSLATION) {
const learningTracksYamlAbsPaths = walk(learningTracks, yamlWalkOptions).sort()
const learningTracksYamlRelPaths = learningTracksYamlAbsPaths.map(p => slash(path.relative(rootDir, p)))
learningTracksToLint = zip(learningTracksYamlRelPaths, learningTracksYamlAbsPaths)
+
+ // Feature versions
+ const featureVersionsYamlAbsPaths = walk(featureVersionsDir, yamlWalkOptions).sort()
+ const featureVersionsYamlRelPaths = featureVersionsYamlAbsPaths.map(p => slash(path.relative(rootDir, p)))
+ featureVersionsToLint = zip(featureVersionsYamlRelPaths, featureVersionsYamlAbsPaths)
} else {
// get all translated markdown or yaml files by comparing files changed to main branch
const changedFilesRelPaths = execSync('git -c diff.renameLimit=10000 diff --name-only origin/main | egrep "^translations/.*/.+.(yml|md)$"', { maxBuffer: 1024 * 1024 * 100 }).toString().split('\n')
@@ -236,7 +243,14 @@ if (!process.env.TEST_TRANSLATION) {
console.log(`Found ${changedFilesRelPaths.length} translated files.`)
- const { mdRelPaths = [], ymlRelPaths = [], ghesReleaseNotesRelPaths = [], ghaeReleaseNotesRelPaths = [], learningTracksRelPaths = [] } = groupBy(changedFilesRelPaths, (path) => {
+ const {
+ mdRelPaths = [],
+ ymlRelPaths = [],
+ ghesReleaseNotesRelPaths = [],
+ ghaeReleaseNotesRelPaths = [],
+ learningTracksRelPaths = [],
+ featureVersionsRelPaths = [],
+ } = groupBy(changedFilesRelPaths, (path) => {
// separate the changed files to different groups
if (path.endsWith('README.md')) {
return 'throwAway'
@@ -250,13 +264,29 @@ if (!process.env.TEST_TRANSLATION) {
return 'ghaeReleaseNotesRelPaths'
} else if (path.match(/\data\/learning-tracks/)) {
return 'learningTracksRelPaths'
+ } else if (path.match(/\data\/features/)) {
+ return 'featureVersionsRelPaths'
} else {
// we aren't linting the rest
return 'throwAway'
}
})
- const [mdTuples, ymlTuples, ghesReleaseNotesTuples, ghaeReleaseNotesTuples, learningTracksTuples] = [mdRelPaths, ymlRelPaths, ghesReleaseNotesRelPaths, ghaeReleaseNotesRelPaths, learningTracksRelPaths].map(relPaths => {
+ const [
+ mdTuples,
+ ymlTuples,
+ ghesReleaseNotesTuples,
+ ghaeReleaseNotesTuples,
+ learningTracksTuples,
+ featureVersionsTuples
+ ] = [
+ mdRelPaths,
+ ymlRelPaths,
+ ghesReleaseNotesRelPaths,
+ ghaeReleaseNotesRelPaths,
+ learningTracksRelPaths,
+ featureVersionsRelPaths
+ ].map(relPaths => {
const absPaths = relPaths.map(p => path.join(rootDir, p))
return zip(relPaths, absPaths)
})
@@ -266,6 +296,7 @@ if (!process.env.TEST_TRANSLATION) {
ghesReleaseNotesToLint = ghesReleaseNotesTuples
ghaeReleaseNotesToLint = ghaeReleaseNotesTuples
learningTracksToLint = learningTracksTuples
+ featureVersionsToLint = featureVersionsTuples
}
function formatLinkError(message, links) {
@@ -887,6 +918,38 @@ describe('lint learning tracks', () => {
)
})
+describe('lint feature versions', () => {
+ if (featureVersionsToLint.length < 1) return
+ describe.each(featureVersionsToLint)(
+ '%s',
+ (yamlRelPath, yamlAbsPath) => {
+ let dictionary
+
+ beforeAll(async () => {
+ const fileContents = await readFileAsync(yamlAbsPath, 'utf8')
+ dictionary = yaml.load(fileContents, { filename: yamlRelPath })
+ })
+
+ it('matches the schema', () => {
+ const { errors } = revalidator.validate(dictionary, featureVersionsSchema)
+
+ const errorMessage = errors.map(error => {
+ // Make this one message a little more readable than the error we get from revalidator
+ // when additionalProperties is set to false and an additional prop is found.
+ const errorToReport = error.message === 'must not exist' && error.actual.feature
+ ? `feature: '${error.actual.feature}'`
+ : JSON.stringify(error.actual, null, 2)
+
+ return `- [${error.property}]: ${errorToReport}, ${error.message}`
+ })
+ .join('\n')
+
+ expect(errors.length, errorMessage).toBe(0)
+ })
+ }
+ )
+})
+
function validateVersion (version) {
return versionShortNames.includes(version) ||
versionShortNameExceptions.some(exception => version.startsWith(exception))
diff --git a/tests/routing/developer-site-redirects.js b/tests/routing/developer-site-redirects.js
index 53a776d34b02..89e75099c444 100644
--- a/tests/routing/developer-site-redirects.js
+++ b/tests/routing/developer-site-redirects.js
@@ -2,9 +2,10 @@ const path = require('path')
const { eachOfLimit } = require('async')
const enterpriseServerReleases = require('../../lib/enterprise-server-releases')
const { get } = require('../helpers/supertest')
-const restRedirectFixtures = require('../fixtures/rest-redirects')
-const graphqlRedirectFixtures = require('../fixtures/graphql-redirects')
-const developerRedirectFixtures = require('../fixtures/developer-redirects')
+const readJsonFile = require('../../lib/read-json-file')
+const restRedirectFixtures = readJsonFile('./tests/fixtures/rest-redirects.json')
+const graphqlRedirectFixtures = readJsonFile('./tests/fixtures/graphql-redirects.json')
+const developerRedirectFixtures = readJsonFile('./tests/fixtures/developer-redirects.json')
const MAX_CONCURRENT_REQUESTS = 50
diff --git a/tests/routing/top-developer-site-path-redirects.js b/tests/routing/top-developer-site-path-redirects.js
index 233b06ab5b8e..ee033f8cb631 100644
--- a/tests/routing/top-developer-site-path-redirects.js
+++ b/tests/routing/top-developer-site-path-redirects.js
@@ -1,7 +1,6 @@
-const fs = require('fs')
-const path = require('path')
const { head } = require('../helpers/supertest')
-const topOldDeveloperSitePaths = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'tests/fixtures/top-old-developer-site-paths.json')))
+const readJsonFile = require('../../lib/read-json-file')
+const topOldDeveloperSitePaths = readJsonFile('tests/fixtures/top-old-developer-site-paths.json')
jest.useFakeTimers()
diff --git a/tests/unit/feature-flags.js b/tests/unit/feature-flags.js
index 4b41ada884eb..18be5f8207ce 100644
--- a/tests/unit/feature-flags.js
+++ b/tests/unit/feature-flags.js
@@ -1,5 +1,6 @@
require('../../lib/feature-flags')
-const ffs = require('../../feature-flags')
+const readJsonFile = require('../../lib/read-json-file')
+const ffs = readJsonFile('./feature-flags.json')
describe('feature flags', () => {
Object.keys(ffs).forEach(featureName => {
diff --git a/tests/unit/liquid.js b/tests/unit/liquid.js
index 2ef7148489b4..c25a1b659ddd 100644
--- a/tests/unit/liquid.js
+++ b/tests/unit/liquid.js
@@ -1,7 +1,9 @@
const { liquid } = require('../../lib/render-content')
-const middleware = require('../../middleware/contextualizers/short-versions')
+const shortVersionsMiddleware = require('../../middleware/contextualizers/short-versions')
+const featureVersionsMiddleware = require('../../middleware/contextualizers/features')
const allVersions = require('../../lib/all-versions')
const enterpriseServerReleases = require('../../lib/enterprise-server-releases')
+const loadSiteData = require('../../lib/site-data')
const template = `
{% if currentVersion ver_gt "enterprise-server@2.13" %}up to date{% endif %}
@@ -25,6 +27,10 @@ const negativeVersionsTemplate = `
{% ifversion ghes != 3.1 %} I am not GHES 3.1 {% endif %}
`
+const featureVersionsTemplate = `
+ {% if placeholder %} I am placeholder content {% endif %}
+`
+
describe('liquid template parser', () => {
describe('custom operators', () => {
describe('ver_gt', () => {
@@ -68,7 +74,7 @@ describe('liquid template parser', () => {
allVersions,
enterpriseServerReleases
}
- await middleware(req, null, () => {})
+ await shortVersionsMiddleware(req, null, () => {})
const output = await liquid.parseAndRender(shortVersionsTemplate, req.context)
// We should have TWO results because we are supporting two shortcuts
expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am FPT I am FTP or GHES < 3.0')
@@ -81,7 +87,7 @@ describe('liquid template parser', () => {
allVersions,
enterpriseServerReleases
}
- await middleware(req, null, () => {})
+ await shortVersionsMiddleware(req, null, () => {})
const output = await liquid.parseAndRender(shortVersionsTemplate, req.context)
expect(output.trim()).toBe('I am GHAE')
})
@@ -93,7 +99,7 @@ describe('liquid template parser', () => {
allVersions,
enterpriseServerReleases
}
- await middleware(req, null, () => {})
+ await shortVersionsMiddleware(req, null, () => {})
const output = await liquid.parseAndRender(shortVersionsTemplate, req.context)
expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am GHES I am GHES < 3.1 I am FTP or GHES < 3.0')
})
@@ -105,7 +111,7 @@ describe('liquid template parser', () => {
allVersions,
enterpriseServerReleases
}
- await middleware(req, null, () => {})
+ await shortVersionsMiddleware(req, null, () => {})
const output = await liquid.parseAndRender(shortVersionsTemplate, req.context)
expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am GHES I am GHES < 3.1 I am 3.0 only')
})
@@ -117,7 +123,7 @@ describe('liquid template parser', () => {
allVersions,
enterpriseServerReleases
}
- await middleware(req, null, () => {})
+ await shortVersionsMiddleware(req, null, () => {})
const output = await liquid.parseAndRender(negativeVersionsTemplate, req.context)
expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am not GHES I am not GHES 3.1')
})
@@ -129,7 +135,7 @@ describe('liquid template parser', () => {
allVersions,
enterpriseServerReleases
}
- await middleware(req, null, () => {})
+ await shortVersionsMiddleware(req, null, () => {})
const output = await liquid.parseAndRender(negativeVersionsTemplate, req.context)
expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am not GHAE I am not GHES 3.1')
})
@@ -141,9 +147,59 @@ describe('liquid template parser', () => {
allVersions,
enterpriseServerReleases
}
- await middleware(req, null, () => {})
+ await shortVersionsMiddleware(req, null, () => {})
const output = await liquid.parseAndRender(negativeVersionsTemplate, req.context)
expect(output.replace(/\s\s+/g, ' ').trim()).toBe('I am not GHAE')
})
})
+
+ describe('feature versions', () => {
+ // Create a fake req so we can test the feature versions middleware
+ const req = { language: 'en', query: {} }
+
+ let siteData
+ beforeAll(async () => {
+ const allSiteData = await loadSiteData()
+ siteData = allSiteData.en.site
+ })
+
+ test('does not render in FPT because feature is not available in FPT', async () => {
+ req.context = {
+ currentVersion: 'free-pro-team@latest',
+ page: {},
+ allVersions,
+ enterpriseServerReleases,
+ site: siteData
+ }
+ await featureVersionsMiddleware(req, null, () => {})
+ const outputFpt = await liquid.parseAndRender(featureVersionsTemplate, req.context)
+ expect(outputFpt.includes('placeholder content')).toBe(false)
+ })
+
+ test('renders in GHES because feature is available in GHES', async () => {
+ req.context = {
+ currentVersion: `enterprise-server@${enterpriseServerReleases.latest}`,
+ page: {},
+ allVersions,
+ enterpriseServerReleases,
+ site: siteData
+ }
+ await featureVersionsMiddleware(req, null, () => {})
+ const outputFpt = await liquid.parseAndRender(featureVersionsTemplate, req.context)
+ expect(outputFpt.includes('placeholder content')).toBe(true)
+ })
+
+ test('renders in GHAE because feature is available in GHAE', async () => {
+ req.context = {
+ currentVersion: 'github-ae@latest',
+ page: {},
+ allVersions,
+ enterpriseServerReleases,
+ site: siteData
+ }
+ await featureVersionsMiddleware(req, null, () => {})
+ const outputFpt = await liquid.parseAndRender(featureVersionsTemplate, req.context)
+ expect(outputFpt.includes('placeholder content')).toBe(true)
+ })
+ })
})
diff --git a/tests/unit/page.js b/tests/unit/page.js
index bdb23cdee864..2711fb932ab9 100644
--- a/tests/unit/page.js
+++ b/tests/unit/page.js
@@ -1,7 +1,8 @@
const path = require('path')
const cheerio = require('cheerio')
const Page = require('../../lib/page')
-const prerenderedObjects = require('../../lib/graphql/static/prerendered-objects')
+const readJsonFile = require('../../lib/read-json-file')
+const prerenderedObjects = readJsonFile('./lib/graphql/static/prerendered-objects.json')
const allVersions = require('../../lib/all-versions')
const enterpriseServerReleases = require('../../lib/enterprise-server-releases')
const nonEnterpriseDefaultVersion = require('../../lib/non-enterprise-default-version')
@@ -534,6 +535,48 @@ describe('Page class', () => {
expect(nonEnterpriseDefaultPlan in page.versions).toBe(false)
expect(page.versions['enterprise-server']).toBe('*')
})
+
+ test('feature versions frontmatter', async () => {
+ // This fixture file has the frontmatter:
+ //
+ // versions:
+ // fpt: '*'
+ // ghes: '*'
+ // feature: 'placeholder'
+ //
+ // and placeholder.yml has:
+ //
+ // versions:
+ // ghes: '<2.22'
+ // ghae: '*'
+ //
+ // So we expect to get the versioning from both.
+ const page = await Page.init({
+ relativePath: 'feature-versions-frontmatter.md',
+ basePath: path.join(__dirname, '../fixtures'),
+ languageCode: 'en'
+ })
+
+ // Test the raw page data.
+ expect(page.versions.fpt).toBe('*')
+ expect(page.versions.ghes).toBe('>2.21')
+ expect(page.versions.ghae).toBeUndefined()
+ // The `feature` prop gets deleted by lib/get-applicable-versions, so it's undefined.
+ expect(page.versions.feature).toBeUndefined()
+
+ // Test the resolved versioning, where GHES releases specified in frontmatter and in
+ // feature versions are combined (i.e., one doesn't overwrite the other).
+ // We can't test that GHES 2.21 is _not_ included here (which it shouldn't be),
+ // because lib/get-applicable-versions only returns currently supported versions,
+ // so as soon as 2.21 is deprecated, a test for that _not_ to exist will not be meaningful.
+ // But by testing that the _latest_ GHES version is returned, we can ensure that the
+ // the frontmatter GHES `*` is not being overwritten by the placeholder's GHES `<2.22`.
+ expect(page.applicableVersions.includes('free-pro-team@latest')).toBe(true)
+ expect(page.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true)
+ expect(page.applicableVersions.includes('github-ae@latest')).toBe(true)
+ expect(page.applicableVersions.includes('feature')).toBe(false)
+ expect(page.applicableVersions.includes('placeholder')).toBe(false)
+ })
})
describe('platform specific content', () => {