diff --git a/.github/actions/spell-check/allow/code.txt b/.github/actions/spell-check/allow/code.txt index b9be3f82a47d..e8d396a68215 100644 --- a/.github/actions/spell-check/allow/code.txt +++ b/.github/actions/spell-check/allow/code.txt @@ -367,3 +367,14 @@ Nonpaged # XAML Untargeted + +# Monaco Editor / Puppeteer +cdp +Cdp +crdownload +networkidle + +# Monaco Editor languages +kotlin +ksh +pde diff --git a/.github/scripts/generate-monaco-languages.js b/.github/scripts/generate-monaco-languages.js new file mode 100644 index 000000000000..20502600c93f --- /dev/null +++ b/.github/scripts/generate-monaco-languages.js @@ -0,0 +1,230 @@ +/** + * generate-monaco-languages.js + * + * Generates monaco_languages.json using Puppeteer to run the existing + * generateLanguagesJson.html in a headless browser. This exactly mirrors + * the manual process described in doc/devdocs/common/FilePreviewCommon.md. + * + * Usage: node generate-monaco-languages.js + */ + +"use strict"; + +const path = require("path"); +const fs = require("fs"); +const http = require("http"); + +const monacoDir = process.argv[2]; +if (!monacoDir) { + console.error("Usage: node generate-monaco-languages.js "); + process.exit(1); +} + +const absMonacoDir = path.resolve(monacoDir); +const outputPath = path.join(absMonacoDir, "monaco_languages.json"); +const htmlPath = path.join(absMonacoDir, "generateLanguagesJson.html"); + +if (!fs.existsSync(htmlPath)) { + console.error(`generateLanguagesJson.html not found at: ${htmlPath}`); + process.exit(1); +} + +async function main() { + let server; + let browser; + + try { + // Step 1: Start a local HTTP server serving the Monaco directory. + // The generateLanguagesJson.html must be served over HTTP because + // browsers block ES module imports and AMD require from file:// URLs. + server = await startServer(absMonacoDir); + const port = server.address().port; + console.log(`Local server started on port ${port}`); + + // Step 2: Launch headless browser via Puppeteer + const puppeteer = require("puppeteer"); + browser = await puppeteer.launch({ + headless: true, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + }); + + const page = await browser.newPage(); + + // Step 3: Set up download interception. + // generateLanguagesJson.html creates an element and clicks it to + // trigger a download of monaco_languages.json. We intercept this + // using CDP to redirect downloads to a temp directory. + const downloadDir = path.join( + absMonacoDir, + ".monaco-download-tmp" + ); + fs.mkdirSync(downloadDir, { recursive: true }); + + try { + const cdp = await browser.target().createCDPSession(); + await cdp.send("Browser.setDownloadBehavior", { + behavior: "allow", + downloadPath: downloadDir, + }); + + const pageCdp = await page.createCDPSession(); + await pageCdp.send("Page.setDownloadBehavior", { + behavior: "allow", + downloadPath: downloadDir, + }); + } catch (err) { + throw new Error( + `Failed to configure download behavior via CDP: ${err.message}` + ); + } + + // Step 4: Navigate to the generator page. + // The page auto-loads Monaco, registers custom languages, calls + // getLanguages(), and triggers a download of the JSON. + console.log("Navigating to generateLanguagesJson.html..."); + await page.goto(`http://localhost:${port}/generateLanguagesJson.html`, { + waitUntil: "networkidle0", + timeout: 60000, + }); + + // Step 5: Wait for the download to complete. + const downloadedFile = await waitForDownload( + downloadDir, + "monaco_languages.json", + 30000 + ); + + // Step 6: Move the downloaded file to the target location. + const downloadedContent = fs.readFileSync(downloadedFile, "utf-8"); + + // Validate the content is valid JSON before writing + const parsed = JSON.parse(downloadedContent); + if (!parsed.list || !Array.isArray(parsed.list)) { + throw new Error( + "Downloaded JSON does not have the expected { list: [...] } structure" + ); + } + + fs.writeFileSync(outputPath, downloadedContent, "utf-8"); + console.log( + `monaco_languages.json written with ${parsed.list.length} languages.` + ); + } catch (err) { + console.error("Failed to generate monaco_languages.json:", err.message); + process.exit(1); + } finally { + if (browser) { + await browser.close().catch(() => {}); + } + if (server) { + server.close(); + } + // Clean up temp download directory AFTER browser is closed + const downloadDir = path.join(absMonacoDir, ".monaco-download-tmp"); + if (fs.existsSync(downloadDir)) { + fs.rmSync(downloadDir, { recursive: true, force: true }); + } + } +} + +/** + * Starts a simple HTTP server that serves static files from the given + * directory. Supports .js, .html, .css, .json, .ttf MIME types. + */ +function startServer(rootDir) { + return new Promise((resolve, reject) => { + const mimeTypes = { + ".html": "text/html", + ".js": "application/javascript", + ".mjs": "application/javascript", + ".css": "text/css", + ".json": "application/json", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".svg": "image/svg+xml", + ".png": "image/png", + }; + + const server = http.createServer((req, res) => { + const urlPath = decodeURIComponent(req.url.split("?")[0]); + const filePath = path.join(rootDir, urlPath); + + // Security: ensure we don't serve files outside rootDir + const resolvedRoot = path.resolve(rootDir); + const resolvedPath = path.resolve(rootDir, urlPath); + if ( + resolvedPath !== resolvedRoot && + !resolvedPath.startsWith(resolvedRoot + path.sep) + ) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + + if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) { + res.writeHead(404); + res.end("Not Found"); + return; + } + + const ext = path.extname(resolvedPath).toLowerCase(); + const contentType = mimeTypes[ext] || "application/octet-stream"; + + const content = fs.readFileSync(resolvedPath); + res.writeHead(200, { "Content-Type": contentType }); + res.end(content); + }); + + server.listen(0, "127.0.0.1", () => { + resolve(server); + }); + + server.on("error", reject); + }); +} + +/** + * Waits for a file to appear in the download directory. + * Puppeteer downloads may have a .crdownload suffix while in progress. + */ +function waitForDownload(downloadDir, expectedFilename, timeoutMs) { + return new Promise((resolve, reject) => { + const startTime = Date.now(); + + const check = () => { + const files = fs.readdirSync(downloadDir); + + // Check for the expected file (not a .crdownload partial) + const targetFile = files.find( + (f) => f === expectedFilename && !f.endsWith(".crdownload") + ); + + if (targetFile) { + const filePath = path.join(downloadDir, targetFile); + // Ensure file has content (not still being written) + const stat = fs.statSync(filePath); + if (stat.size > 0) { + resolve(filePath); + return; + } + } + + if (Date.now() - startTime > timeoutMs) { + reject( + new Error( + `Timed out waiting for ${expectedFilename} download after ${timeoutMs}ms. ` + + `Files in download dir: ${files.join(", ") || "(empty)"}` + ) + ); + return; + } + + setTimeout(check, 500); + }; + + check(); + }); +} + +main(); diff --git a/.github/scripts/tests/validate-monaco-update.tests.ps1 b/.github/scripts/tests/validate-monaco-update.tests.ps1 new file mode 100644 index 000000000000..1f6c9921fcaa --- /dev/null +++ b/.github/scripts/tests/validate-monaco-update.tests.ps1 @@ -0,0 +1,371 @@ +<# +.SYNOPSIS + Validates that a Monaco Editor update was performed correctly. + +.DESCRIPTION + Runs a series of checks against the Monaco Editor files in the repository + to ensure the update is valid and no regressions were introduced. + + Tests: + - loader.js exists and contains version info + - monaco_languages.json is valid JSON with expected structure + - All expected built-in Monaco languages are present + - All PowerToys custom languages are registered + - Custom language extension mappings are present + - Monaco directory structure is intact + - No empty/corrupt core files + - Version consistency across Monaco files + +.PARAMETER RepoRoot + The root of the PowerToys repository. Defaults to the repo root + relative to this script. + +.EXAMPLE + ./validate-monaco-update.tests.ps1 + ./validate-monaco-update.tests.ps1 -RepoRoot "C:\src\PowerToys" +#> +[CmdletBinding()] +param( + [Parameter()] + [string]$RepoRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if (-not $RepoRoot) { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot ".." ".." "..")).Path +} + +$monacoDir = Join-Path $RepoRoot "src" "Monaco" +$monacoSrcDir = Join-Path $monacoDir "monacoSRC" +$minDir = Join-Path $monacoSrcDir "min" +$loaderJsPath = Join-Path $minDir "vs" "loader.js" +$languagesJsonPath = Join-Path $monacoDir "monaco_languages.json" +$customLangsDir = Join-Path $monacoDir "customLanguages" + +$testsPassed = 0 +$testsFailed = 0 +$testResults = @() + +function Assert-Test { + param( + [string]$Name, + [scriptblock]$Test + ) + + try { + $result = & $Test + if ($result -eq $false) { + throw "Assertion returned false" + } + $script:testsPassed++ + $script:testResults += [PSCustomObject]@{ Name = $Name; Status = "PASS"; Error = $null } + Write-Host " [PASS] $Name" -ForegroundColor Green + } + catch { + $script:testsFailed++ + $script:testResults += [PSCustomObject]@{ Name = $Name; Status = "FAIL"; Error = $_.Exception.Message } + Write-Host " [FAIL] $Name" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Red + } +} + +Write-Host "=== Monaco Editor Update Validation ===" -ForegroundColor Cyan +Write-Host "Repository root: $RepoRoot" +Write-Host "" + +# ─── Test Group 1: Directory Structure ──────────────────────────────── +Write-Host "--- Directory Structure ---" -ForegroundColor Yellow + +Assert-Test "Monaco directory exists" { + Test-Path $monacoDir +} + +Assert-Test "monacoSRC directory exists" { + Test-Path $monacoSrcDir +} + +Assert-Test "min directory exists" { + Test-Path $minDir +} + +Assert-Test "vs subdirectory exists" { + Test-Path (Join-Path $minDir "vs") +} + +Assert-Test "editor directory exists" { + Test-Path (Join-Path $minDir "vs" "editor") +} + +Assert-Test "basic-languages directory exists" { + Test-Path (Join-Path $minDir "vs" "basic-languages") +} + +Assert-Test "base directory exists" { + Test-Path (Join-Path $minDir "vs" "base") +} + +Assert-Test "language directory exists" { + Test-Path (Join-Path $minDir "vs" "language") +} + +Assert-Test "customLanguages directory exists" { + Test-Path $customLangsDir +} + +# ─── Test Group 2: Core Files ───────────────────────────────────────── +Write-Host "`n--- Core Files ---" -ForegroundColor Yellow + +Assert-Test "loader.js exists" { + Test-Path $loaderJsPath +} + +Assert-Test "loader.js is not empty" { + (Get-Item $loaderJsPath).Length -gt 0 +} + +Assert-Test "loader.js contains version string" { + $content = Get-Content $loaderJsPath -Raw + $content -match 'Version:\s*\d+\.\d+\.\d+' +} + +Assert-Test "editor.main.js exists" { + Test-Path (Join-Path $minDir "vs" "editor" "editor.main.js") +} + +Assert-Test "editor.main.js is not empty" { + (Get-Item (Join-Path $minDir "vs" "editor" "editor.main.js")).Length -gt 0 +} + +Assert-Test "editor.main.css exists" { + Test-Path (Join-Path $minDir "vs" "editor" "editor.main.css") +} + +Assert-Test "monacoSpecialLanguages.js exists" { + Test-Path (Join-Path $monacoDir "monacoSpecialLanguages.js") +} + +Assert-Test "generateLanguagesJson.html exists" { + Test-Path (Join-Path $monacoDir "generateLanguagesJson.html") +} + +Assert-Test "index.html exists" { + Test-Path (Join-Path $monacoDir "index.html") +} + +Assert-Test "customTokenThemeRules.js exists" { + Test-Path (Join-Path $monacoDir "customTokenThemeRules.js") +} + +# ─── Test Group 3: monaco_languages.json ────────────────────────────── +Write-Host "`n--- monaco_languages.json ---" -ForegroundColor Yellow + +Assert-Test "monaco_languages.json exists" { + Test-Path $languagesJsonPath +} + +Assert-Test "monaco_languages.json is not empty" { + (Get-Item $languagesJsonPath).Length -gt 0 +} + +$languagesJson = $null +Assert-Test "monaco_languages.json is valid JSON" { + $script:languagesJson = Get-Content $languagesJsonPath -Raw | ConvertFrom-Json + $null -ne $script:languagesJson +} + +Assert-Test "JSON has 'list' property" { + $null -ne $languagesJson.list +} + +Assert-Test "Language list is a non-empty array" { + $languagesJson.list.Count -gt 0 +} + +Assert-Test "Minimum language count check (at least 80 languages)" { + $languagesJson.list.Count -ge 80 +} + +# Core built-in languages that should always be present +$expectedBuiltinLanguages = @( + "plaintext", "javascript", "typescript", "html", "css", "json", + "xml", "markdown", "yaml", "python", "java", "csharp", "cpp", + "go", "rust", "ruby", "php", "sql", "shell", "powershell", + "dockerfile", "bat", "fsharp", "lua", "r", "swift", "kotlin", + "scala", "perl", "dart", "ini", "vb" +) + +$languageIds = $languagesJson.list | ForEach-Object { $_.id } + +foreach ($lang in $expectedBuiltinLanguages) { + Assert-Test "Built-in language '$lang' is present" { + $lang -in $languageIds + } +} + +# ─── Test Group 4: PowerToys Custom Languages ───────────────────────── +Write-Host "`n--- PowerToys Custom Languages ---" -ForegroundColor Yellow + +$expectedCustomLanguages = @( + @{ Id = "reg"; Extensions = @(".reg") }, + @{ Id = "gitignore"; Extensions = @(".gitignore") }, + @{ Id = "srt"; Extensions = @(".srt") } +) + +foreach ($custom in $expectedCustomLanguages) { + Assert-Test "Custom language '$($custom.Id)' is registered" { + $custom.Id -in $languageIds + } + + foreach ($ext in $custom.Extensions) { + Assert-Test "Custom language '$($custom.Id)' has extension '$ext'" { + $lang = $languagesJson.list | Where-Object { $_.id -eq $custom.Id } + if ($null -eq $lang) { throw "Language not found" } + $ext -in $lang.extensions + } + } +} + +# Custom language definition files exist +$expectedCustomFiles = @("reg.js", "gitignore.js", "srt.js") +foreach ($file in $expectedCustomFiles) { + Assert-Test "Custom language file '$file' exists" { + Test-Path (Join-Path $customLangsDir $file) + } +} + +# ─── Test Group 5: Custom Language Extensions ───────────────────────── +Write-Host "`n--- Custom Language Extensions ---" -ForegroundColor Yellow + +$expectedExtensions = @( + @{ Id = "cppExt"; Extensions = @(".ino", ".pde") }, + @{ Id = "xmlExt"; Extensions = @(".wsdl", ".csproj", ".vcxproj", ".vbproj", ".fsproj", ".resx", ".resw") }, + @{ Id = "txtExt"; Extensions = @(".sln", ".log") }, + @{ Id = "razorExt"; Extensions = @(".razor") }, + @{ Id = "vbExt"; Extensions = @(".vbs") }, + @{ Id = "iniExt"; Extensions = @(".inf") }, + @{ Id = "shellExt"; Extensions = @(".ksh", ".zsh", ".bsh") } +) + +foreach ($ext in $expectedExtensions) { + Assert-Test "Extension mapping '$($ext.Id)' is registered" { + $ext.Id -in $languageIds + } + + # Check all extensions from each mapping + foreach ($extension in $ext.Extensions) { + Assert-Test "Extension mapping '$($ext.Id)' has extension '$extension'" { + $lang = $languagesJson.list | Where-Object { $_.id -eq $ext.Id } + if ($null -eq $lang) { throw "Language not found" } + $extension -in $lang.extensions + } + } +} + +# ─── Test Group 6: Language Entry Structure ─────────────────────────── +Write-Host "`n--- Language Entry Structure ---" -ForegroundColor Yellow + +Assert-Test "Every language has an 'id' field" { + $missing = @($languagesJson.list | Where-Object { -not $_.id -or $_.id.Trim() -eq "" }) + $missing.Count -eq 0 +} + +Assert-Test "No duplicate language IDs" { + $ids = $languagesJson.list | ForEach-Object { $_.id } + $uniqueIds = $ids | Select-Object -Unique + $ids.Count -eq $uniqueIds.Count +} + +Assert-Test "Languages with extensions have array-type extensions" { + $withExtensions = @($languagesJson.list | Where-Object { + ($_.PSObject.Properties.Name -contains "extensions") -and ($null -ne $_.extensions) + }) + $invalid = @($withExtensions | Where-Object { $_.extensions -isnot [array] }) + $invalid.Count -eq 0 +} + +# Spot-check known extension-to-language mappings +$extensionMappings = @( + @{ Extension = ".js"; ExpectedLanguage = "javascript" }, + @{ Extension = ".py"; ExpectedLanguage = "python" }, + @{ Extension = ".cs"; ExpectedLanguage = "csharp" }, + @{ Extension = ".html"; ExpectedLanguage = "html" }, + @{ Extension = ".css"; ExpectedLanguage = "css" }, + @{ Extension = ".json"; ExpectedLanguage = "json" }, + @{ Extension = ".ts"; ExpectedLanguage = "typescript" }, + @{ Extension = ".yaml"; ExpectedLanguage = "yaml" }, + @{ Extension = ".go"; ExpectedLanguage = "go" }, + @{ Extension = ".rs"; ExpectedLanguage = "rust" } +) + +foreach ($mapping in $extensionMappings) { + Assert-Test "Extension '$($mapping.Extension)' maps to '$($mapping.ExpectedLanguage)'" { + $lang = $languagesJson.list | Where-Object { $_.id -eq $mapping.ExpectedLanguage } + if ($null -eq $lang) { throw "Language '$($mapping.ExpectedLanguage)' not found" } + $mapping.Extension -in $lang.extensions + } +} + +# ─── Test Group 7: Version Consistency ──────────────────────────────── +Write-Host "`n--- Version Consistency ---" -ForegroundColor Yellow + +Assert-Test "All NLS files in editor directory have matching versions" { + $editorDir = Join-Path $minDir "vs" "editor" + $nlsFiles = Get-ChildItem -Path $editorDir -Filter "editor.main.nls*.js" -ErrorAction SilentlyContinue + if ($nlsFiles.Count -eq 0) { throw "No NLS files found" } + + $loaderContent = Get-Content $loaderJsPath -Raw + $null = $loaderContent -match 'Version:\s*(\d+\.\d+\.\d+)' + $loaderVersion = $Matches[1] + + foreach ($nlsFile in $nlsFiles) { + $content = Get-Content $nlsFile.FullName -Raw -ErrorAction SilentlyContinue + if ($content -and $content -match 'Version:\s*(\d+\.\d+\.\d+)') { + if ($Matches[1] -ne $loaderVersion) { + throw "Version mismatch in $($nlsFile.Name): expected $loaderVersion, found $($Matches[1])" + } + } + } + $true +} + +Assert-Test "basic-languages files have matching versions" { + $loaderContent = Get-Content $loaderJsPath -Raw + $null = $loaderContent -match 'Version:\s*(\d+\.\d+\.\d+)' + $loaderVersion = $Matches[1] + + $basicLangsDir = Join-Path $minDir "vs" "basic-languages" + # Spot-check a few language files + $checkFiles = @("javascript/javascript.js", "python/python.js", "html/html.js") + foreach ($file in $checkFiles) { + $filePath = Join-Path $basicLangsDir $file + if (Test-Path $filePath) { + $content = Get-Content $filePath -Raw + if ($content -match 'Version:\s*(\d+\.\d+\.\d+)') { + if ($Matches[1] -ne $loaderVersion) { + throw "Version mismatch in $file: expected $loaderVersion, found $($Matches[1])" + } + } + } + } + $true +} + +# ─── Summary ────────────────────────────────────────────────────────── +Write-Host "" +Write-Host "=== Test Summary ===" -ForegroundColor Cyan +Write-Host "Passed: $testsPassed" -ForegroundColor Green +Write-Host "Failed: $testsFailed" -ForegroundColor $(if ($testsFailed -gt 0) { "Red" } else { "Green" }) +Write-Host "Total: $($testsPassed + $testsFailed)" + +if ($testsFailed -gt 0) { + Write-Host "`nFailed tests:" -ForegroundColor Red + $testResults | Where-Object { $_.Status -eq "FAIL" } | ForEach-Object { + Write-Host " - $($_.Name): $($_.Error)" -ForegroundColor Red + } + exit 1 +} + +Write-Host "`nAll tests passed!" -ForegroundColor Green +exit 0 diff --git a/.github/scripts/update-monaco-editor.ps1 b/.github/scripts/update-monaco-editor.ps1 new file mode 100644 index 000000000000..08066e4b4f1c --- /dev/null +++ b/.github/scripts/update-monaco-editor.ps1 @@ -0,0 +1,162 @@ +<# +.SYNOPSIS + Updates the Monaco Editor in PowerToys to the latest (or specified) version. + +.DESCRIPTION + This script automates the Monaco Editor update process described in + doc/devdocs/common/FilePreviewCommon.md: + 1. Downloads Monaco editor via npm + 2. Copies the min folder into src/Monaco/monacoSRC/ + 3. Generates the monaco_languages.json file using Puppeteer (headless browser) + +.PARAMETER Version + The Monaco Editor npm version to install. Defaults to "latest". + +.PARAMETER RepoRoot + The root of the PowerToys repository. Defaults to the repo root relative to this script. + +.EXAMPLE + ./update-monaco-editor.ps1 + ./update-monaco-editor.ps1 -Version "0.55.1" +#> +[CmdletBinding()] +param( + [Parameter()] + [string]$Version = "latest", + + [Parameter()] + [string]$RepoRoot +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +if (-not $RepoRoot) { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot ".." "..")).Path +} + +$monacoDir = Join-Path $RepoRoot "src" "Monaco" +$monacoSrcDir = Join-Path $monacoDir "monacoSRC" +$languagesJsonPath = Join-Path $monacoDir "monaco_languages.json" +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "monaco-update-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))" + +Write-Host "=== Monaco Editor Update Script ===" +Write-Host "Repository root: $RepoRoot" +Write-Host "Target version: $Version" +Write-Host "Temp directory: $tempDir" + +# Verify prerequisites +$npmPath = Get-Command npm -ErrorAction SilentlyContinue +if (-not $npmPath) { + throw "npm is required but not found in PATH. Please install Node.js." +} + +$nodePath = Get-Command node -ErrorAction SilentlyContinue +if (-not $nodePath) { + throw "node is required but not found in PATH. Please install Node.js." +} + +# Verify repo structure +if (-not (Test-Path $monacoDir)) { + throw "Monaco directory not found at: $monacoDir" +} + +try { + # Step 1: Download Monaco via npm + Write-Host "`n--- Step 1: Downloading Monaco Editor ($Version) via npm ---" + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + Push-Location $tempDir + try { + $versionSpec = if ($Version -eq "latest") { "monaco-editor@latest" } else { "monaco-editor@$Version" } + npm init -y 2>&1 | Out-Null + npm install $versionSpec 2>&1 + + if ($LASTEXITCODE -ne 0) { + throw "npm install failed with exit code $LASTEXITCODE" + } + } + finally { + Pop-Location + } + + $downloadedMinDir = Join-Path $tempDir "node_modules" "monaco-editor" "min" + if (-not (Test-Path $downloadedMinDir)) { + throw "Downloaded Monaco min directory not found at: $downloadedMinDir" + } + + # Detect the downloaded version from loader.js + $loaderJsPath = Join-Path $downloadedMinDir "vs" "loader.js" + if (-not (Test-Path $loaderJsPath)) { + throw "loader.js not found in downloaded Monaco package" + } + + $loaderContent = Get-Content $loaderJsPath -Raw + if ($loaderContent -match 'Version:\s*(\d+\.\d+\.\d+)') { + $newVersion = $Matches[1] + Write-Host "Downloaded Monaco version: $newVersion" + } + else { + Write-Warning "Could not detect version from loader.js" + $newVersion = $Version + } + + # Step 2: Replace monacoSRC/min folder + Write-Host "`n--- Step 2: Replacing monacoSRC/min with new version ---" + $targetMinDir = Join-Path $monacoSrcDir "min" + + if (Test-Path $targetMinDir) { + Write-Host "Removing existing min directory..." + Remove-Item -Recurse -Force $targetMinDir + } + + Write-Host "Copying new min directory..." + Copy-Item -Recurse -Force $downloadedMinDir $targetMinDir + + # Step 3: Generate monaco_languages.json using Puppeteer + Write-Host "`n--- Step 3: Generating monaco_languages.json (Puppeteer) ---" + + # Install Puppeteer in the temp directory + Push-Location $tempDir + try { + Write-Host "Installing Puppeteer..." + npm install puppeteer 2>&1 + + if ($LASTEXITCODE -ne 0) { + throw "npm install puppeteer failed with exit code $LASTEXITCODE" + } + } + finally { + Pop-Location + } + + $generateScript = Join-Path $PSScriptRoot "generate-monaco-languages.js" + + # Set NODE_PATH so the generate script can find puppeteer from the temp dir + $env:NODE_PATH = Join-Path $tempDir "node_modules" + node $generateScript $monacoDir + + if ($LASTEXITCODE -ne 0) { + throw "Failed to generate monaco_languages.json (exit code: $LASTEXITCODE)" + } + + if (-not (Test-Path $languagesJsonPath)) { + throw "monaco_languages.json was not generated at: $languagesJsonPath" + } + + Write-Host "`n=== Monaco Editor update complete ===" + Write-Host "Updated to version: $newVersion" + Write-Host "Languages JSON: $languagesJsonPath" + + # Output the new version for the workflow to use + Write-Output "MONACO_VERSION=$newVersion" +} +finally { + # Clean up temp directory + if (Test-Path $tempDir) { + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue + } + + # Remove NODE_PATH override + Remove-Item Env:\NODE_PATH -ErrorAction SilentlyContinue +} diff --git a/.github/workflows/update-monaco-editor.yml b/.github/workflows/update-monaco-editor.yml new file mode 100644 index 000000000000..87910acfa65e --- /dev/null +++ b/.github/workflows/update-monaco-editor.yml @@ -0,0 +1,135 @@ +# Update Monaco Editor +# +# Automates the Monaco Editor update process described in +# doc/devdocs/common/FilePreviewCommon.md: +# 1. Downloads the latest (or specified) Monaco Editor from npm +# 2. Replaces src/Monaco/monacoSRC/min with the new version +# 3. Regenerates monaco_languages.json via Puppeteer (headless browser) +# 4. Runs validation tests +# 5. Creates a pull request with the changes +# +# Trigger manually via workflow_dispatch. +# Uncomment the schedule block below to enable weekly automatic checks. + +name: Update Monaco Editor + +on: + workflow_dispatch: + inputs: + version: + description: 'Monaco Editor version (e.g. "0.55.1"). Leave empty for latest.' + required: false + default: '' + type: string + + # Uncomment the following to enable weekly automatic update checks: + # schedule: + # - cron: '0 9 * * 1' # Every Monday at 9:00 UTC + +permissions: + contents: write + pull-requests: write + +jobs: + update-monaco: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Get current Monaco version + id: current_version + shell: bash + run: | + CURRENT=$(grep -oP 'Version:\s*\K[\d.]+' src/Monaco/monacoSRC/min/vs/loader.js || echo "unknown") + echo "version=$CURRENT" >> "$GITHUB_OUTPUT" + echo "Current Monaco version: $CURRENT" + + - name: Run Monaco update script + id: update + shell: pwsh + run: | + $version = '${{ inputs.version }}' + if ([string]::IsNullOrWhiteSpace($version)) { + $version = 'latest' + } + $output = & ./.github/scripts/update-monaco-editor.ps1 -Version $version -RepoRoot $env:GITHUB_WORKSPACE + # Extract version from script output + $versionLine = $output | Select-String -Pattern '^MONACO_VERSION=' | Select-Object -First 1 + if ($versionLine) { + $newVersion = $versionLine.ToString().Split('=')[1] + echo "new_version=$newVersion" >> $env:GITHUB_OUTPUT + Write-Host "New Monaco version: $newVersion" + } + + - name: Run validation tests + shell: pwsh + run: | + ./.github/scripts/tests/validate-monaco-update.tests.ps1 -RepoRoot $env:GITHUB_WORKSPACE + + - name: Check for changes + id: changes + shell: bash + run: | + if git diff --quiet; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No changes detected - Monaco may already be up to date." + else + echo "has_changes=true" >> "$GITHUB_OUTPUT" + CHANGED_FILES=$(git diff --stat | tail -1) + echo "changed_files=$CHANGED_FILES" >> "$GITHUB_OUTPUT" + fi + + - name: Create pull request + if: steps.changes.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "Update Monaco Editor to ${{ steps.update.outputs.new_version }}" + title: "Update Monaco Editor from ${{ steps.current_version.outputs.version }} to ${{ steps.update.outputs.new_version }}" + body: | + ## Summary + + Automated update of the Monaco Editor dependency. + + **Previous version:** ${{ steps.current_version.outputs.version }} + **New version:** ${{ steps.update.outputs.new_version }} + + ## Changes + + - Updated `src/Monaco/monacoSRC/min/` with the new Monaco Editor release + - Regenerated `src/Monaco/monaco_languages.json` + + ## Validation + + The following automated checks passed: + - ✅ `loader.js` contains valid version header + - ✅ Directory structure intact (`vs/editor/`, `vs/basic-languages/`, `vs/base/`, `vs/language/`) + - ✅ `monaco_languages.json` is valid JSON with expected structure + - ✅ All expected built-in languages are present (32+ core languages verified) + - ✅ All PowerToys custom languages registered (`reg`, `gitignore`, `srt`) + - ✅ All custom extension mappings present (`cppExt`, `xmlExt`, `txtExt`, etc.) + - ✅ No duplicate language IDs + - ✅ Version consistency across Monaco files + - ✅ Extension-to-language mappings verified (`.js`→javascript, `.py`→python, etc.) + + ## Manual Verification + + Before merging, please verify: + - [ ] File Explorer Dev File Preview works correctly + - [ ] Peek module previews code files properly + - [ ] Registry Preview module functions normally + + ## Reference + + - [Monaco Editor update docs](doc/devdocs/common/FilePreviewCommon.md#update-monaco-editor) + - [Monaco Editor releases](https://github.com/microsoft/monaco-editor/releases) + branch: automated/update-monaco-editor + delete-branch: true + labels: | + dependencies