Skip to content

Commit f3607ab

Browse files
fix: sync Crowdin README headings and add tests
Extend README localization sync to manage both heading strings and navigation links in Crowdin, using canonical heading parsing to preserve/repair section emojis across locales. Update validation helpers to generate heading translations, enforce section-count safety, and add node:test coverage for heading source selection, emoji correction, and missing-section failures. Also add an npm test script and run tests in the lint workflow, and update README/workflow naming for the new Virtual Gamepads section and Game Stores emoji change.
1 parent a1cdf06 commit f3607ab

7 files changed

Lines changed: 282 additions & 38 deletions

.github/workflows/lint.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,8 @@ jobs:
2828
- name: Lint
2929
run: npm run lint
3030

31+
- name: Test
32+
run: npm test
33+
3134
- name: Validate README navigation
3235
run: npm run validate:readme-navigation

.github/workflows/sync-readme-navigation-crowdin.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
name: Sync README navigation to Crowdin
2+
name: Sync README headings and navigation to Crowdin
33
permissions:
44
contents: read
55

@@ -31,7 +31,7 @@ jobs:
3131
- name: Install dependencies
3232
run: npm ci --ignore-scripts
3333

34-
- name: Update and approve localized README navigation
34+
- name: Update and approve localized README headings and navigation
3535
run: npm run sync:readme-navigation-crowdin
3636
env:
3737
CROWDIN_TOKEN: ${{ secrets.CROWDIN_TOKEN }}

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<a href="#-game-stores">Game Stores</a> •
1414
<a href="#-frontends">Frontends</a> •
1515
<a href="#-virtual-displays">Virtual Displays</a> •
16+
<a href="#-virtual-gamepads">Virtual Gamepads</a> •
1617
<a href="#-scripts">Scripts</a> •
1718
<a href="#-testing">Testing</a> •
1819
<a href="#-guides">Guides</a>
@@ -46,7 +47,7 @@
4647
- [sunshine_utils](https://github.com/designer-living/sunshine_utils) - Utilities for using with Sunshine / Moonlight / Playnite game streaming.
4748
- [vuinputd](https://github.com/joleuger/vuinputd) - Enabling container-friendly input handling for Sunshine on Linux.
4849

49-
## 🎮 Game Stores
50+
## 🛒 Game Stores
5051
- [Epic Games](https://www.epicgames.com) - Download and play PC Games of every genre. They have mods, DLC, and Free Games too! Games for everyone.
5152
- [GOG](https://www.gog.com) - Download the best classic and new games on Windows, Mac & Linux. A vast selection of titles, DRM-free, with free goodies.
5253
- [Steam](https://store.steampowered.com) - The ultimate destination for playing, discussing, and creating games.
@@ -64,6 +65,9 @@
6465
- [VirtualDisplayDriver_Wizard](https://github.com/sofmeright/VirtualDisplayDriver_Wizard) - A GUI tool that can integrate with other software such as Sunshine for efficient manipulation of Indirect Display Driver Sample (IddSample) implementations.
6566
- [virtual-display-rs](https://github.com/MolotovCherry/virtual-display-rs) - A Windows virtual display driver to add multiple virtual monitors to your PC! For Win10+. Works with VR, obs, streaming software, etc.
6667

68+
## 🎮 Virtual Gamepads
69+
- [Virtual HID Driver](https://github.com/LizardByte/libvirtualhid) - User-mode virtual gamepads for Windows. Adds more gamepad types to Windows, such as Xbox One|Series, DualSense, Nintendo Switch Pro, and more to come in the future.
70+
6771
## 📜 Scripts
6872
- [Dummy Plug Automation](https://github.com/XenHat/dummy-plug-automation) - Automation for remote streaming using a dummy plug on Linux.
6973
- [FrlToggle](https://github.com/FrogTheFrog/frl-toggle) - Nvidia's Frame Rate Limiter.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"scripts": {
77
"lint": "awesome-lint",
88
"sync:readme-navigation-crowdin": "node scripts/sync-readme-navigation-crowdin.mjs",
9+
"test": "node --test scripts/*.test.mjs",
910
"validate:readme-navigation": "node scripts/validate-readme-navigation.mjs"
1011
},
1112
"devDependencies": {

scripts/sync-readme-navigation-crowdin.mjs

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import path from 'node:path'
22
import process from 'node:process'
3+
import { fileURLToPath } from 'node:url'
34

45
import { Client as CrowdinClient } from '@crowdin/crowdin-api-client'
56

67
import {
8+
createHeadingTranslations,
79
createNavigationTranslation,
810
findLocalizedReadmes,
11+
parseCanonicalHeadings,
912
parseNavigationLinks
1013
} from './validate-readme-navigation.mjs'
1114

@@ -58,7 +61,25 @@ function createLanguageIds (locales, targetLanguages) {
5861
return languageIds
5962
}
6063

61-
async function findNavigationString (crowdin) {
64+
function findHeadingSources (sourceStrings, navigation) {
65+
const navigationLinks = parseNavigationLinks(navigation.text.split(/\r?\n/))
66+
67+
if (navigationLinks.length === 0) {
68+
throw new Error('The Crowdin README navigation source string has no links')
69+
}
70+
71+
return navigationLinks.map(link => {
72+
const source = findOne(
73+
sourceStrings,
74+
`README heading source string for ${link.text}`,
75+
item => !item.isHidden && parseCanonicalHeadings([`## ${item.text}`])[0]?.text === link.text
76+
)
77+
const canonical = parseCanonicalHeadings([`## ${source.text}`])[0]
78+
return { canonical, source }
79+
})
80+
}
81+
82+
async function findReadmeStrings (crowdin) {
6283
const branches = await crowdin.sourceFilesApi.withFetchAll()
6384
.listProjectBranches(PROJECT_ID)
6485
const branch = findOne(data(branches), `Crowdin branch named ${BRANCH_NAME}`, item => {
@@ -73,13 +94,22 @@ async function findNavigationString (crowdin) {
7394

7495
const strings = await crowdin.sourceStringsApi.withFetchAll()
7596
.listProjectStrings(PROJECT_ID, { fileId: readme.id })
97+
const sourceStrings = data(strings)
98+
const navigation = findOne(
99+
sourceStrings,
100+
`README navigation string with context ${NAVIGATION_CONTEXT}`,
101+
item => item.context === NAVIGATION_CONTEXT
102+
)
103+
const headings = findHeadingSources(sourceStrings, navigation)
76104

77-
return findOne(data(strings), `README navigation string with context ${NAVIGATION_CONTEXT}`, item => {
78-
return item.context === NAVIGATION_CONTEXT
79-
})
105+
return {
106+
canonicalHeadings: headings.map(heading => heading.canonical),
107+
headings: headings.map(heading => heading.source),
108+
navigation
109+
}
80110
}
81111

82-
function localizedReadme (localeRoot, file, expectedHeadingCount, languageIds) {
112+
function localizedReadme (localeRoot, file, canonicalHeadings, languageIds) {
83113
const relativeFile = path.relative(localeRoot, file)
84114
const parts = relativeFile.split(path.sep)
85115

@@ -91,9 +121,10 @@ function localizedReadme (localeRoot, file, expectedHeadingCount, languageIds) {
91121
if (!languageId) throw new Error(`No Crowdin language mapping for locale/${parts[0]}`)
92122

93123
return {
94-
expected: createNavigationTranslation(file, expectedHeadingCount),
95124
file: `locale/${parts.join('/')}`,
96-
languageId
125+
headings: createHeadingTranslations(file, canonicalHeadings),
126+
languageId,
127+
navigation: createNavigationTranslation(file, canonicalHeadings)
97128
}
98129
}
99130

@@ -146,6 +177,17 @@ async function synchronizeTranslation (crowdin, stringId, target, dryRun) {
146177
return actions
147178
}
148179

180+
function actionVerb (actions, dryRun) {
181+
if (actions.length === 0) return 'Checked'
182+
if (dryRun) return 'Would update'
183+
return 'Updated'
184+
}
185+
186+
function logActions (target, label, actions, dryRun) {
187+
const status = actions.length === 0 ? 'already correct and approved' : actions.join(', ')
188+
console.log(`${actionVerb(actions, dryRun)} ${target.file} ${label}: ${status}`)
189+
}
190+
149191
async function main () {
150192
const token = process.env.CROWDIN_TOKEN
151193
if (!token) throw new Error('CROWDIN_TOKEN is required')
@@ -154,45 +196,58 @@ async function main () {
154196
const root = process.cwd()
155197
const localeRoot = path.join(root, 'locale')
156198
const crowdin = new CrowdinClient({ token })
157-
const [projectResponse, navigationString] = await Promise.all([
199+
const [projectResponse, readmeStrings] = await Promise.all([
158200
crowdin.projectsGroupsApi.getProject(PROJECT_ID),
159-
findNavigationString(crowdin)
201+
findReadmeStrings(crowdin)
160202
])
161-
const expectedHeadingCount = parseNavigationLinks(navigationString.text.split(/\r?\n/)).length
203+
const canonicalHeadings = readmeStrings.canonicalHeadings
204+
const sourceLinkCount = parseNavigationLinks(readmeStrings.navigation.text.split(/\r?\n/)).length
162205

163-
if (expectedHeadingCount === 0) {
164-
throw new Error('The Crowdin README navigation source string has no links')
206+
if (sourceLinkCount !== canonicalHeadings.length) {
207+
throw new Error(
208+
`The Crowdin README navigation source has ${sourceLinkCount} links; expected ${canonicalHeadings.length}`
209+
)
165210
}
166211

167212
const readmes = findLocalizedReadmes(localeRoot)
168213
const locales = readmes.map(file => path.relative(localeRoot, path.dirname(file)))
169214
const languageIds = createLanguageIds(locales, projectResponse.data.targetLanguages)
170215
const targets = readmes
171-
.map(file => localizedReadme(localeRoot, file, expectedHeadingCount, languageIds))
216+
.map(file => localizedReadme(localeRoot, file, canonicalHeadings, languageIds))
172217
.sort((left, right) => left.file.localeCompare(right.file, 'en'))
173218

174219
if (targets.length === 0) throw new Error('No localized README files were found')
175220

176221
for (const target of targets) {
177-
const actions = await synchronizeTranslation(
222+
for (const [index, heading] of target.headings.entries()) {
223+
const actions = await synchronizeTranslation(
224+
crowdin,
225+
readmeStrings.headings[index].id,
226+
{ expected: heading, languageId: target.languageId },
227+
dryRun
228+
)
229+
logActions(target, `heading ${index + 1}`, actions, dryRun)
230+
}
231+
232+
const navigationActions = await synchronizeTranslation(
178233
crowdin,
179-
navigationString.id,
180-
target,
234+
readmeStrings.navigation.id,
235+
{ expected: target.navigation, languageId: target.languageId },
181236
dryRun
182237
)
183-
const status = actions.length === 0 ? 'already correct and approved' : actions.join(', ')
184-
let verb = 'Updated'
185-
if (actions.length === 0) verb = 'Checked'
186-
else if (dryRun) verb = 'Would update'
187-
console.log(`${verb} ${target.file}: ${status}`)
238+
logActions(target, 'navigation', navigationActions, dryRun)
188239
}
189240

190-
console.log(`${dryRun ? 'Checked' : 'Synchronized'} ${targets.length} localized README navigation translations.`)
241+
console.log(`${dryRun ? 'Checked' : 'Synchronized'} ${targets.length} localized README heading and navigation translations.`)
191242
}
192243

193-
try {
194-
await main()
195-
} catch (error) {
196-
console.error(error)
197-
process.exitCode = 1
244+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
245+
try {
246+
await main()
247+
} catch (error) {
248+
console.error(error)
249+
process.exitCode = 1
250+
}
198251
}
252+
253+
export { findHeadingSources }

scripts/validate-readme-navigation.mjs

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,42 @@ function headingText (markdown) {
4444
.trim()
4545
}
4646

47+
function splitHeading (text) {
48+
const firstSpace = text.indexOf(' ')
49+
const prefix = firstSpace > 0 ? text.slice(0, firstSpace) : ''
50+
51+
if (/\p{Extended_Pictographic}/u.test(prefix)) {
52+
return {
53+
emoji: prefix,
54+
text: text.slice(firstSpace + 1).trim()
55+
}
56+
}
57+
58+
return { emoji: '', text }
59+
}
60+
61+
function parseCanonicalHeadings (lines) {
62+
const headings = []
63+
64+
for (const line of lines) {
65+
const match = /^(#{1,6})[ \t](.+)$/.exec(line)
66+
if (!match) continue
67+
68+
const level = match[1].length
69+
const sourceText = headingText(match[2].replace(/[ \t]#+[ \t]*$/, ''))
70+
const heading = splitHeading(sourceText)
71+
72+
if (level === 2 && heading.emoji) {
73+
headings.push({
74+
emoji: heading.emoji,
75+
text: heading.text
76+
})
77+
}
78+
}
79+
80+
return headings
81+
}
82+
4783
function parseHeadings (lines) {
4884
const slugger = new GithubSlugger()
4985
const navigationHeadings = []
@@ -73,6 +109,53 @@ function parseHeadings (lines) {
73109
return navigationHeadings
74110
}
75111

112+
function readLocalizedHeadings (file, canonicalHeadings) {
113+
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/)
114+
const h2Count = lines.filter(line => /^##[ \t]+/.test(line)).length
115+
116+
if (h2Count <= canonicalHeadings.length) {
117+
throw new Error(
118+
`${file} has fewer than ${canonicalHeadings.length} navigation headings plus its final section`
119+
)
120+
}
121+
122+
const slugger = new GithubSlugger()
123+
const headings = []
124+
let navigationIndex = 0
125+
126+
for (const line of lines) {
127+
const match = /^(#{1,6})[ \t](.+)$/.exec(line)
128+
if (!match) continue
129+
130+
const level = match[1].length
131+
const currentText = headingText(match[2].replace(/[ \t]#+[ \t]*$/, ''))
132+
let expectedText = currentText
133+
134+
if (level === 2 && navigationIndex < canonicalHeadings.length) {
135+
const canonical = canonicalHeadings[navigationIndex]
136+
const localized = splitHeading(currentText)
137+
expectedText = `${canonical.emoji} ${localized.text}`
138+
headings.push({
139+
expected: expectedText,
140+
slug: slugger.slug(expectedText),
141+
text: localized.text
142+
})
143+
navigationIndex++
144+
continue
145+
}
146+
147+
slugger.slug(expectedText)
148+
}
149+
150+
if (headings.length !== canonicalHeadings.length) {
151+
throw new Error(
152+
`${file} has ${headings.length} navigation headings; expected ${canonicalHeadings.length}`
153+
)
154+
}
155+
156+
return headings
157+
}
158+
76159
function parseNavigationLinks (lines) {
77160
const firstHeading = lines.findIndex(line => /^#{1,6}[ \t]+/.test(line))
78161
const preambleLines = lines.slice(0, firstHeading === -1 ? lines.length : firstHeading)
@@ -92,15 +175,13 @@ function parseNavigationLinks (lines) {
92175
return links
93176
}
94177

95-
function createNavigationTranslation (file, expectedHeadingCount) {
96-
const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/)
97-
const headings = parseHeadings(lines)
178+
function createHeadingTranslations (file, canonicalHeadings) {
179+
return readLocalizedHeadings(file, canonicalHeadings)
180+
.map(heading => heading.expected)
181+
}
98182

99-
if (headings.length !== expectedHeadingCount) {
100-
throw new Error(
101-
`${file} has ${headings.length} navigation headings; expected ${expectedHeadingCount}`
102-
)
103-
}
183+
function createNavigationTranslation (file, canonicalHeadings) {
184+
const headings = readLocalizedHeadings(file, canonicalHeadings)
104185

105186
const links = headings.map((heading, index) => {
106187
const separator = index === headings.length - 1 ? '' : ' •'
@@ -229,8 +310,10 @@ function main () {
229310
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main()
230311

231312
export {
313+
createHeadingTranslations,
232314
createNavigationTranslation,
233315
findLocalizedReadmes,
316+
parseCanonicalHeadings,
234317
parseNavigationLinks,
235318
validateReadme
236319
}

0 commit comments

Comments
 (0)