Skip to content

Commit 54f611a

Browse files
authored
Merge pull request #5888 from github/repo-sync
repo sync
2 parents bd3691b + 6127df4 commit 54f611a

10 files changed

Lines changed: 218 additions & 36 deletions

File tree

content/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ defaultPlatform: linux
207207
- type: `String`. This should reference learning tracks' names defined in [`data/learning-tracks/*.yml`](../data/learning-tracks/README.md).
208208
- Optional
209209

210-
**Note: the first learning track is by-default the featured track.*
210+
**Note: the featured track is set by a specific property in the learning tracks YAML. See that [README](../data/learning-tracks/README.md) for details.*
211211

212212
### `includeGuides`
213213
- Purpose: Render a list of articles, filterable by `type` and `topics`. Only applicable when used with `layout: product-sublanding`.

data/learning-tracks/README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
11
# Learning Tracks (aka Learning Paths)
22

3-
Learning tracks are a collection of articles that help you master a particular subject. Learning tracks are defined on a per-product basis.
3+
Learning tracks are a collection of articles that help you master a particular subject. Learning tracks are defined on a per-product basis. For example, see https://docs.github.com/en/actions/guides.
4+
5+
Learning track data for a product is defined in two places:
6+
7+
1. A simple array of learning track names is defined in the product sublanding index page frontmatter.
8+
9+
For example, in `content/actions/guides/index.md`:
10+
```
11+
learningTracks:
12+
- getting_started
13+
- continuous_integration
14+
- continuous_deployment
15+
- deploy_to_the_cloud
16+
- hosting_your_own_runners
17+
- create_actions
18+
```
19+
20+
2. Additional data for each track is defined in a YAML file named for the **product** in the `data` directory.
21+
22+
For example, in `data/learning-tracks/actions.yml`, each of the items from the content file's `learningTracks` array is represented with additional data such as `title`, `description`, and an array of `guides` links.
23+
24+
One learning track in this YAML **per version** must be designated as a "featured" learning track via `featured_track: true`, which will set it to appear at the top of the product sublanding page. A test will fail if this property is missing.
25+
26+
The `featured_track` property can be a simple boolean (i.e., `featured_track: true`) or it can be a string that includes versioning statements (e.g., `featured_track: '{% if currentVersion == "free-pro-team@latest" %}true{% else %}false{% endif %}'`). If you use versioning, you'll have multiple `featured_track`s per YML file, but make sure that only one will render in each currently supported version. A test will fail if there are more or less than one featured link for each version.
27+
28+
Versioning for learning tracks is processed at page render time. The code lives in [`lib/learning-tracks.js`](lib/learning-tracks.js), which is called by `page.render()`. The processed learning tracks are then rendered by `layouts/product-sublanding.html`.
29+
30+
The schema for validating the learning track YAML lives in [`tests/helpers/schemas/learning-tracks-schema.js`](tests/helpers/schemas/learning-tracks-schema.js) and is exercised by [`tests/content/lint-files.js`](tests/content/lint-files.js).

data/learning-tracks/actions.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ getting_started:
77
- /actions/learn-github-actions/essential-features-of-github-actions
88
- /actions/learn-github-actions/managing-complex-workflows
99
- /actions/learn-github-actions/security-hardening-for-github-actions
10+
featured_track: true
1011
continuous_integration:
1112
title: 'Build and test code'
1213
description: 'You can create custom continuous integration (CI) workflows right in your repository.'

layouts/product-sublanding.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ <h1 class="my-3 font-mktg">{% data ui.product_sublanding.guides %}</h1>
2222
</header>
2323

2424
<!-- Featured track -->
25-
{% assign featuredTrack = page.learningTracks[0] %}
26-
{% if featuredTrack %}
25+
{% if page.featuredTrack %}
26+
{% assign featuredTrack = page.featuredTrack %}
2727
<div class="mb-6 position-relative overflow-hidden mr-n3 ml-n3 px-3">
2828
<ul class="list-style-none d-flex flex-nowrap overflow-x-scroll px-2 feature-track">
2929
<li class="px-2 d-flex flex-shrink-0">
@@ -58,14 +58,14 @@ <h3 class="font-mktg h3-mktg my-4 color-text-primary">{{ guide.title }}</h3>
5858
{% endif %}
5959

6060
{% assign learningTracks = page.learningTracks %}
61-
{% if learningTracks %}
61+
{% if learningTracks and learningTracks.size > 0 %}
6262
<div class="border-top py-6">
6363
<h2 class="mb-3 font-mktg">{% data ui.product_sublanding.learning_paths %}</h2>
6464
<div class="lead-mktg color-text-secondary f4 description-text">{% data ui.product_sublanding.learning_paths_desc %}</div>
6565

6666
<!-- Learning tracks -->
6767
<div class="d-flex flex-wrap flex-items-start my-5 gutter">
68-
{% for track in page.learningTracks offset:1 %}
68+
{% for track in learningTracks %}
6969
<div class="my-3 px-4 col-12 col-md-6 learning-track">
7070
<div class="Box js-show-more-container d-flex flex-column">
7171
<div class="Box-header bg-gradient--blue-pink p-4 d-flex flex-1 flex-items-start flex-wrap">

lib/page.js

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const pathUtils = require('./path-utils')
1111
const Permalink = require('./permalink')
1212
const languages = require('./languages')
1313
const renderContent = require('./render-content')
14+
const processLearningTracks = require('./process-learning-tracks')
1415
const { renderReact } = require('./react/engine')
1516
const { productMap } = require('./all-products')
1617
const slash = require('slash')
@@ -207,23 +208,10 @@ class Page {
207208
this.permissions = await renderContent(this.rawPermissions, context)
208209
}
209210

211+
// Learning tracks may contain Liquid and need to have versioning processed.
210212
if (this.learningTracks) {
211-
const learningTracks = []
212-
for await (const rawTrackName of this.rawLearningTracks) {
213-
// Track names in frontmatter may include Liquid conditionals
214-
const renderedTrackName = await renderContent(rawTrackName, context, { textOnly: true, encodeEntities: true })
215-
if (!renderedTrackName) continue
216-
217-
const track = context.site.data['learning-tracks'][context.currentProduct][renderedTrackName]
218-
if (!track) continue
219-
learningTracks.push({
220-
trackName: renderedTrackName,
221-
title: await renderContent(track.title, context, { textOnly: true, encodeEntities: true }),
222-
description: await renderContent(track.description, context, { textOnly: true, encodeEntities: true }),
223-
guides: await getLinkData(track.guides, context)
224-
})
225-
}
226-
213+
const { featuredTrack, learningTracks } = await processLearningTracks(this.rawLearningTracks, context)
214+
this.featuredTrack = featuredTrack
227215
this.learningTracks = learningTracks
228216
}
229217

lib/process-learning-tracks.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
const renderContent = require('./render-content')
2+
const getLinkData = require('./get-link-data')
3+
4+
const renderOpts = { textOnly: true, encodeEntities: true }
5+
6+
// This module returns an object that contains a single featured learning track
7+
// and an array of all the other learning tracks for the current version.
8+
module.exports = async function processLearningTracks (rawLearningTracks, context) {
9+
const learningTracks = []
10+
11+
let featuredTrack
12+
13+
for await (const rawTrackName of rawLearningTracks) {
14+
let isFeaturedTrack = false
15+
16+
// Track names in frontmatter may include Liquid conditionals.
17+
const renderedTrackName = await renderContent(rawTrackName, context, renderOpts)
18+
if (!renderedTrackName) continue
19+
20+
// Find the data for the current product and track name.
21+
const track = context.site.data['learning-tracks'][context.currentProduct][renderedTrackName]
22+
if (!track) continue
23+
24+
const learningTrack = {
25+
trackName: renderedTrackName,
26+
title: await renderContent(track.title, context, renderOpts),
27+
description: await renderContent(track.description, context, renderOpts),
28+
// getLinkData respects versioning and only returns guides available in the current version;
29+
// if no guides are available, the learningTrack.guides property will be an empty array.
30+
guides: await getLinkData(track.guides, context)
31+
}
32+
33+
// Determine if this is the featured track.
34+
if (track.featured_track) {
35+
// Featured track properties may be booleans or string that include Liquid conditionals with versioning.
36+
// We need to parse any strings to determine if the featured track is relevant for this version.
37+
isFeaturedTrack = track.featured_track === true || (await renderContent(track.featured_track, context, renderOpts) === 'true')
38+
39+
if (isFeaturedTrack) {
40+
featuredTrack = learningTrack
41+
}
42+
}
43+
44+
// Only add the track to the array of tracks if there are guides in this version and it's not the featured track.
45+
if (learningTrack.guides.length && !isFeaturedTrack) {
46+
learningTracks.push(learningTrack)
47+
}
48+
}
49+
50+
return { featuredTrack, learningTracks }
51+
}

middleware/context.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ module.exports = async function contextualize (req, res, next) {
5151
req.context.siteTree = siteTree
5252
req.context.pages = pageMap
5353

54+
// TODO we should create new data directories for these example files instead of using variable files
5455
if (productMap[req.context.currentProduct]) {
5556
req.context.productCodeExamples = req.context.site.data.variables[`${productMap[req.context.currentProduct].id}_code_examples`]
5657
req.context.productCommunityExamples = req.context.site.data.variables[`${productMap[req.context.currentProduct].id}_community_examples`]
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
module.exports = {
2+
properties: {
3+
type: 'object',
4+
additionalProperties: false,
5+
patternProperties: {
6+
'^[a-zA-Z-_]+$': {
7+
type: 'object',
8+
properties: {
9+
title: {
10+
type: 'string',
11+
required: true
12+
},
13+
description: {
14+
type: 'string',
15+
required: true
16+
},
17+
guides: {
18+
type: 'array',
19+
items: { type: 'string' },
20+
required: true
21+
},
22+
featured_track: {
23+
type: 'boolean'
24+
}
25+
}
26+
}
27+
}
28+
}
29+
}

tests/linting/lint-files.js

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,19 @@ const frontmatter = require('../../lib/frontmatter')
1111
const languages = require('../../lib/languages')
1212
const { tags } = require('../../lib/liquid-tags/extended-markdown')
1313
const ghesReleaseNotesSchema = require('../helpers/schemas/release-notes-schema')
14+
const learningTracksSchema = require('../helpers/schemas/learning-tracks-schema')
1415
const renderContent = require('../../lib/render-content')
1516
const { execSync } = require('child_process')
17+
const allVersions = Object.keys(require('../../lib/all-versions'))
18+
const enterpriseServerVersions = allVersions.filter(v => v.startsWith('enterprise-server@'))
1619

1720
const rootDir = path.join(__dirname, '../..')
1821
const contentDir = path.join(rootDir, 'content')
1922
const reusablesDir = path.join(rootDir, 'data/reusables')
2023
const variablesDir = path.join(rootDir, 'data/variables')
2124
const glossariesDir = path.join(rootDir, 'data/glossaries')
2225
const ghesReleaseNotesDir = path.join(rootDir, 'data/release-notes')
26+
const learningTracks = path.join(rootDir, 'data/learning-tracks')
2327

2428
const languageCodes = Object.keys(languages)
2529

@@ -167,7 +171,7 @@ const yamlWalkOptions = {
167171
}
168172

169173
// different lint rules apply to different content types
170-
let mdToLint, ymlToLint, releaseNotesToLint
174+
let mdToLint, ymlToLint, releaseNotesToLint, learningTracksToLint
171175

172176
if (!process.env.TEST_TRANSLATION) {
173177
// compile lists of all the files we want to lint
@@ -196,8 +200,13 @@ if (!process.env.TEST_TRANSLATION) {
196200

197201
// GHES release notes
198202
const ghesReleaseNotesYamlAbsPaths = walk(ghesReleaseNotesDir, yamlWalkOptions).sort()
199-
const ghesReleaseNotesYamlRelPaths = ghesReleaseNotesYamlAbsPaths.map(p => path.relative(rootDir, p))
203+
const ghesReleaseNotesYamlRelPaths = ghesReleaseNotesYamlAbsPaths.map(p => slash(path.relative(rootDir, p)))
200204
releaseNotesToLint = zip(ghesReleaseNotesYamlRelPaths, ghesReleaseNotesYamlAbsPaths)
205+
206+
// Learning tracks
207+
const learningTracksYamlAbsPaths = walk(learningTracks, yamlWalkOptions).sort()
208+
const learningTracksYamlRelPaths = learningTracksYamlAbsPaths.map(p => slash(path.relative(rootDir, p)))
209+
learningTracksToLint = zip(learningTracksYamlRelPaths, learningTracksYamlAbsPaths)
201210
} else {
202211
// get all translated markdown or yaml files by comparing files changed to main branch
203212
const changedFilesRelPaths = execSync('git diff --name-only origin/main | egrep "^translations/.*/.+.(yml|md)$"', { maxBuffer: 1024 * 1024 * 100 }).toString().split('\n')
@@ -207,7 +216,7 @@ if (!process.env.TEST_TRANSLATION) {
207216

208217
console.log(`Found ${changedFilesRelPaths.length} translated files.`)
209218

210-
const { mdRelPaths = [], ymlRelPaths = [], releaseNotesRelPaths = [] } = groupBy(changedFilesRelPaths, (path) => {
219+
const { mdRelPaths = [], ymlRelPaths = [], releaseNotesRelPaths = [], learningTracksRelPaths = [] } = groupBy(changedFilesRelPaths, (path) => {
211220
// separate the changed files to different groups
212221
if (path.endsWith('README.md')) {
213222
return 'throwAway'
@@ -217,20 +226,23 @@ if (!process.env.TEST_TRANSLATION) {
217226
return 'ymlRelPaths'
218227
} else if (path.match(/\/data\/release-notes\//i)) {
219228
return 'releaseNotesRelPaths'
229+
} else if (path.match(/\data\/learning-tracks/)) {
230+
return 'learningTracksRelPaths'
220231
} else {
221232
// we aren't linting the rest
222233
return 'throwAway'
223234
}
224235
})
225236

226-
const [mdTuples, ymlTuples, releaseNotesTuples] = [mdRelPaths, ymlRelPaths, releaseNotesRelPaths].map(relPaths => {
237+
const [mdTuples, ymlTuples, releaseNotesTuples, learningTracksTuples] = [mdRelPaths, ymlRelPaths, releaseNotesRelPaths, learningTracksRelPaths].map(relPaths => {
227238
const absPaths = relPaths.map(p => path.join(rootDir, p))
228239
return zip(relPaths, absPaths)
229240
})
230241

231242
mdToLint = mdTuples
232243
ymlToLint = ymlTuples
233244
releaseNotesToLint = releaseNotesTuples
245+
learningTracksToLint = learningTracksTuples
234246
}
235247

236248
function formatLinkError (message, links) {
@@ -694,3 +706,60 @@ describe('lint release notes', () => {
694706
}
695707
)
696708
})
709+
710+
describe('lint learning tracks', () => {
711+
if (learningTracksToLint.length < 1) return
712+
describe.each(learningTracksToLint)(
713+
'%s',
714+
(yamlRelPath, yamlAbsPath) => {
715+
let dictionary
716+
717+
beforeAll(async () => {
718+
const fileContents = await readFileAsync(yamlAbsPath, 'utf8')
719+
dictionary = yaml.safeLoad(fileContents, { filename: yamlRelPath })
720+
})
721+
722+
it('matches the schema', () => {
723+
const { errors } = revalidator.validate(dictionary, learningTracksSchema)
724+
const errorMessage = errors.map(error => `- [${error.property}]: ${error.actual}, ${error.message}`).join('\n')
725+
expect(errors.length, errorMessage).toBe(0)
726+
})
727+
728+
it('has one and only one featured track per version', async () => {
729+
const featuredTracks = {}
730+
const context = { enterpriseServerVersions }
731+
732+
await Promise.all(allVersions.map(async (version) => {
733+
const featuredTracksPerVersion = (await Promise.all(Object.values(dictionary).map(async (entry) => {
734+
if (!entry.featured_track) return
735+
context.currentVersion = version
736+
const isFeaturedLink = typeof entry.featured_track === 'boolean' || (await renderContent(entry.featured_track, context, { textOnly: true, encodeEntities: true }) === 'true')
737+
return isFeaturedLink
738+
})))
739+
.filter(Boolean)
740+
741+
featuredTracks[version] = featuredTracksPerVersion.length
742+
}))
743+
744+
Object.entries(featuredTracks).forEach(([version, numOfFeaturedTracks]) => {
745+
const errorMessage = `Expected one featured learning track for ${version} in ${yamlAbsPath}`
746+
expect(numOfFeaturedTracks, errorMessage).toBe(1)
747+
})
748+
})
749+
750+
it('contains valid liquid', () => {
751+
const toLint = []
752+
Object.values(dictionary).forEach(({ title, description }) => {
753+
toLint.push(title)
754+
toLint.push(description)
755+
})
756+
757+
toLint.forEach(element => {
758+
expect(() => renderContent.liquid.parse(element), `${element} contains invalid liquid`)
759+
.not
760+
.toThrow()
761+
})
762+
})
763+
}
764+
)
765+
})

0 commit comments

Comments
 (0)