diff --git a/pages/Build-Site.ps1 b/pages/Build-Site.ps1 index 612f3d98b..9a8b86c08 100644 --- a/pages/Build-Site.ps1 +++ b/pages/Build-Site.ps1 @@ -6,10 +6,12 @@ param( $SourceRoot = (Resolve-Path $SourceRoot).Path $PagesPath = Join-Path $SourceRoot "pages" +$ArticlesPath = Join-Path $PagesPath "articles" $DocumentationPath = Join-Path $SourceRoot "documentation" $CmdletsPath = Join-Path $PagesPath "cmdlets" $DocFxConfigPath = Join-Path $PagesPath "docfx.json" $SitePath = Join-Path $PagesPath "_site" +$MarkdownOutputPath = Join-Path $SitePath "markdown" $CmdletIndexPath = Join-Path $CmdletsPath "index.md" $AliasTemplatePath = Join-Path $CmdletsPath "alias.template" @@ -35,6 +37,39 @@ function Clear-GeneratedCmdletPages { } } +function Copy-MarkdownFiles { + param( + [string]$SourcePath, + [string]$DestinationPath + ) + + if (!(Test-Path $SourcePath)) { + return + } + + Get-ChildItem -Path $SourcePath -Filter "*.md" -File -Recurse | ForEach-Object { + $relativePath = [System.IO.Path]::GetRelativePath($SourcePath, $_.FullName) + $destinationFilePath = Join-Path $DestinationPath $relativePath + $destinationFolderPath = Split-Path -Parent $destinationFilePath + New-Item -Path $destinationFolderPath -ItemType Directory -Force | Out-Null + Copy-Item -Path $_.FullName -Destination $destinationFilePath -Force + } +} + +function Copy-MarkdownSourceFiles { + Write-Host "Copying markdown source files to generated site" + + Remove-Item -Path $MarkdownOutputPath -Recurse -Force -ErrorAction SilentlyContinue + New-Item -Path $MarkdownOutputPath -ItemType Directory -Force | Out-Null + + Get-ChildItem -Path $PagesPath -Filter "*.md" -File | ForEach-Object { + Copy-Item -Path $_.FullName -Destination (Join-Path $MarkdownOutputPath $_.Name) -Force + } + + Copy-MarkdownFiles -SourcePath $ArticlesPath -DestinationPath (Join-Path $MarkdownOutputPath "articles") + Copy-MarkdownFiles -SourcePath $CmdletsPath -DestinationPath (Join-Path $MarkdownOutputPath "cmdlets") +} + New-Item -Path $CmdletsPath -ItemType Directory -Force | Out-Null $cmdletIndexTemplateBytes = [System.IO.File]::ReadAllBytes($CmdletIndexPath) @@ -238,6 +273,8 @@ try { throw "DocFX build failed with exit code $LASTEXITCODE" } + Copy-MarkdownSourceFiles + if (!$SkipPublish) { if ([string]::IsNullOrWhiteSpace($PublishPath)) { $publishCandidate = Join-Path (Split-Path -Parent $SourceRoot) "gh-pages" diff --git a/pages/articles/buildingdocumentation.md b/pages/articles/buildingdocumentation.md index 4170e3072..bcd3194ab 100644 --- a/pages/articles/buildingdocumentation.md +++ b/pages/articles/buildingdocumentation.md @@ -226,6 +226,8 @@ At minimum, verify the following: 1. The Cmdlets link opens the cmdlets index and shows cmdlet pages in the table of contents. 1. Search opens without JavaScript errors. 1. The theme selector in the top navigation can switch between Light, Dark and Auto. +1. The favicon loads from `images/favicon-pnp.svg` on the home page, article pages, and cmdlet pages. +1. The Copy markdown button copies the Markdown source for the home page, an article page, and a cmdlet page. 1. The browser developer tools do not show 404 responses for `public/docfx.min.css`, `public/docfx.min.js`, `public/main.css` or `public/main.js`. DocFX 2.77 and newer emit the vendor assets as minified files. With the modern template, the site should load its built-in assets from the `public` folder and the PnP branding overrides from `public/main.css` and `public/main.js`. If these files return 404 responses, the template stack is not being applied correctly and the site can appear broken or unstyled. diff --git a/pages/docfx.json b/pages/docfx.json index 116d143ae..26f259c3d 100644 --- a/pages/docfx.json +++ b/pages/docfx.json @@ -47,7 +47,7 @@ "repo": "https://github.com/pnp/powershell", "branch": "dev" }, - "_appFaviconPath": "https://c.s-microsoft.com/favicon.ico?v2", + "_appFaviconPath": "images/favicon-pnp.svg", "_description": "PnP PowerShell is an open source, community driven, PowerShell Module designed to work with Microsoft 365.", "_appLogoPath": "images/logo.svg", "_enableSearch": true, diff --git a/pages/images/favicon-pnp.svg b/pages/images/favicon-pnp.svg new file mode 100644 index 000000000..2e26d6c98 --- /dev/null +++ b/pages/images/favicon-pnp.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/pages/templates/pnp-modern/public/main.css b/pages/templates/pnp-modern/public/main.css index 9fad362c8..e328c776b 100644 --- a/pages/templates/pnp-modern/public/main.css +++ b/pages/templates/pnp-modern/public/main.css @@ -157,6 +157,173 @@ article h4 { --bs-alert-link-color: var(--bs-primary-text-emphasis); } +.content > .actionbar { + gap: .5rem; +} + +.content > .actionbar > .pnp-page-actions { + align-items: center; + display: flex; + gap: .5rem; + margin-left: auto; +} + +.pnp-copy-markdown-group { + filter: drop-shadow(0 6px 18px rgba(0, 0, 0, .08)); + position: relative; +} + +.pnp-copy-markdown, +.pnp-copy-markdown-toggle { + align-items: center; + background: var(--bs-body-bg); + border-color: rgba(var(--pnp-header-bg-rgb), .28) !important; + color: var(--bs-body-color); + display: inline-flex; + font-weight: 600; + min-height: 2.25rem; + white-space: nowrap; +} + +.pnp-copy-markdown { + border-bottom-left-radius: 999px; + border-top-left-radius: 999px; + gap: .45rem; + padding-inline: .75rem; +} + +.pnp-copy-markdown-toggle { + border-bottom-right-radius: 999px; + border-left-color: rgba(var(--pnp-header-bg-rgb), .18) !important; + border-top-right-radius: 999px; + justify-content: center; + min-width: 2.35rem; + padding-inline: .55rem; +} + +.pnp-copy-markdown:hover, +.pnp-copy-markdown:focus, +.pnp-copy-markdown:focus-visible, +.pnp-copy-markdown-toggle:hover, +.pnp-copy-markdown-toggle:focus, +.pnp-copy-markdown-toggle:focus-visible, +.pnp-copy-markdown-group.show .pnp-copy-markdown-toggle { + background: var(--pnp-header-bg); + border-color: var(--pnp-highlight) !important; + color: var(--pnp-header-fg); + text-decoration: none; +} + +.pnp-copy-markdown[data-copy-state="copied"] { + background: #198754; + border-color: #198754 !important; + color: #fff; +} + +.pnp-copy-markdown[data-copy-state="failed"] { + background: #B02A37; + border-color: #B02A37 !important; + color: #fff; +} + +.pnp-copy-markdown .bi, +.pnp-copy-markdown-toggle .bi { + font-size: 1rem; + line-height: 1; +} + +.pnp-copy-markdown-menu { + --bs-dropdown-min-width: 17rem; + border-color: rgba(var(--pnp-header-bg-rgb), .16); + border-radius: .75rem; + box-shadow: 0 14px 36px rgba(0, 0, 0, .16); + left: auto; + max-width: calc(100vw - 1.5rem); + padding: .45rem; + right: 0; + top: calc(100% + .45rem); +} + +.pnp-copy-markdown-menu.show { + display: block; +} + +.pnp-copy-markdown-menu-item { + align-items: center; + border-radius: .5rem; + display: flex; + gap: .7rem; + padding: .6rem .65rem; + white-space: normal; +} + +.pnp-copy-markdown-menu-icon { + align-items: center; + border: 1px solid rgba(var(--pnp-header-bg-rgb), .18); + border-radius: .5rem; + color: var(--bs-link-color); + display: inline-flex; + flex: 0 0 2rem; + height: 2rem; + justify-content: center; + width: 2rem; +} + +.pnp-copy-markdown-menu-text { + display: block; + min-width: 0; +} + +.pnp-copy-markdown-menu-label { + align-items: center; + display: flex; + font-weight: 700; + gap: .35rem; + line-height: 1.2; +} + +.pnp-copy-markdown-menu-description { + color: var(--bs-secondary-color); + display: block; + font-size: .875rem; + line-height: 1.25; + margin-top: .15rem; +} + +.pnp-copy-markdown-menu-item:hover .pnp-copy-markdown-menu-icon, +.pnp-copy-markdown-menu-item:focus .pnp-copy-markdown-menu-icon { + background: rgba(var(--pnp-highlight-rgb), .16); + border-color: rgba(var(--pnp-highlight-rgb), .5); +} + +[data-bs-theme="dark"] .pnp-copy-markdown, +[data-bs-theme="dark"] .pnp-copy-markdown-toggle { + background: #202A33; + border-color: rgba(var(--pnp-highlight-rgb), .35) !important; + color: #E8F5FF; +} + +[data-bs-theme="dark"] .pnp-copy-markdown:hover, +[data-bs-theme="dark"] .pnp-copy-markdown:focus, +[data-bs-theme="dark"] .pnp-copy-markdown:focus-visible, +[data-bs-theme="dark"] .pnp-copy-markdown-toggle:hover, +[data-bs-theme="dark"] .pnp-copy-markdown-toggle:focus, +[data-bs-theme="dark"] .pnp-copy-markdown-toggle:focus-visible, +[data-bs-theme="dark"] .pnp-copy-markdown-group.show .pnp-copy-markdown-toggle { + background: var(--pnp-highlight); + color: #06131C; +} + +[data-bs-theme="dark"] .pnp-copy-markdown-menu { + background: #151D24; + border-color: rgba(var(--pnp-highlight-rgb), .26); +} + +[data-bs-theme="dark"] .pnp-copy-markdown-menu-icon { + border-color: rgba(var(--pnp-highlight-rgb), .28); + color: #B9E0FF; +} + pre { position: relative; } @@ -250,6 +417,10 @@ pre > .code-action .bi { content: ""; margin: 0; } + + .pnp-copy-markdown > span { + display: none; + } } body > footer { diff --git a/pages/templates/pnp-modern/public/main.js b/pages/templates/pnp-modern/public/main.js index 2e23bc5b7..e70a5d6cb 100644 --- a/pages/templates/pnp-modern/public/main.js +++ b/pages/templates/pnp-modern/public/main.js @@ -1,3 +1,221 @@ +const copyMarkdownButtonClass = "pnp-copy-markdown"; +const copyMarkdownGroupClass = "pnp-copy-markdown-group"; +const copyMarkdownMenuClass = "pnp-copy-markdown-menu"; + +function getMarkdownSourceUrl() { + const normalizedPath = window.location.pathname.endsWith("/") ? `${window.location.pathname}index.html` : window.location.pathname; + const pathSegments = normalizedPath.split("/").filter(Boolean); + const pageName = pathSegments[pathSegments.length - 1]; + + if (!pageName || !/\.html?$/i.test(pageName)) { + return null; + } + + const markdownFileName = pageName.replace(/\.html?$/i, ".md"); + const cmdletsIndex = pathSegments.lastIndexOf("cmdlets"); + const articlesIndex = pathSegments.lastIndexOf("articles"); + const sectionIndex = Math.max(cmdletsIndex, articlesIndex); + let relativeDepth = 0; + let markdownSegments = ["markdown", markdownFileName]; + + if (sectionIndex >= 0) { + const sectionName = pathSegments[sectionIndex]; + const sectionPathSegments = pathSegments.slice(sectionIndex + 1); + sectionPathSegments[sectionPathSegments.length - 1] = markdownFileName; + markdownSegments = ["markdown", sectionName, ...sectionPathSegments]; + relativeDepth = pathSegments.length - sectionIndex - 1; + } + + return new URL(`${"../".repeat(relativeDepth)}${markdownSegments.join("/")}`, window.location.href); +} + +async function writeTextToClipboard(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.setAttribute("readonly", ""); + textArea.style.position = "fixed"; + textArea.style.top = "0"; + textArea.style.opacity = "0"; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand("copy"); + document.body.removeChild(textArea); +} + +function setCopyMarkdownButtonState(button, state) { + const states = { + ready: { icon: "bi-clipboard", label: "Copy page", disabled: false }, + copying: { icon: "bi-hourglass-split", label: "Copying", disabled: true }, + copied: { icon: "bi-check-lg", label: "Copied", disabled: false }, + failed: { icon: "bi-exclamation-triangle", label: "Copy failed", disabled: false } + }; + const currentState = states[state] ?? states.ready; + const icon = document.createElement("i"); + const label = document.createElement("span"); + + button.dataset.copyState = state; + button.disabled = currentState.disabled; + button.title = currentState.label; + button.setAttribute("aria-label", currentState.label); + icon.className = `bi ${currentState.icon}`; + label.textContent = currentState.label; + button.replaceChildren(icon, label); +} + +function createDropdownItem(elementName, iconClass, label, description, appendedIconClass) { + const item = document.createElement(elementName); + const iconWrap = document.createElement("span"); + const icon = document.createElement("i"); + const textWrap = document.createElement("span"); + const labelWrap = document.createElement("span"); + const labelText = document.createElement("span"); + const descriptionText = document.createElement("span"); + + item.className = "dropdown-item pnp-copy-markdown-menu-item"; + item.setAttribute("role", "menuitem"); + iconWrap.className = "pnp-copy-markdown-menu-icon"; + icon.className = `bi ${iconClass}`; + icon.setAttribute("aria-hidden", "true"); + textWrap.className = "pnp-copy-markdown-menu-text"; + labelWrap.className = "pnp-copy-markdown-menu-label"; + labelText.textContent = label; + descriptionText.className = "pnp-copy-markdown-menu-description"; + descriptionText.textContent = description; + + if (item instanceof HTMLButtonElement) { + item.type = "button"; + } + + labelWrap.appendChild(labelText); + + if (appendedIconClass) { + const appendedIcon = document.createElement("i"); + appendedIcon.className = `bi ${appendedIconClass}`; + appendedIcon.setAttribute("aria-hidden", "true"); + labelWrap.appendChild(appendedIcon); + } + + iconWrap.appendChild(icon); + textWrap.append(labelWrap, descriptionText); + item.append(iconWrap, textWrap); + + return item; +} + +function setCopyMarkdownMenuOpen(buttonGroup, toggleButton, toggleIcon, menu, isOpen) { + buttonGroup.classList.toggle("show", isOpen); + toggleButton.classList.toggle("show", isOpen); + menu.classList.toggle("show", isOpen); + toggleButton.setAttribute("aria-expanded", isOpen.toString()); + toggleIcon.className = `bi ${isOpen ? "bi-chevron-up" : "bi-chevron-down"}`; +} + +async function copyMarkdown(markdownSourceUrl, button) { + setCopyMarkdownButtonState(button, "copying"); + + try { + const response = await fetch(markdownSourceUrl, { cache: "no-store" }); + + if (!response.ok) { + throw new Error(`Unable to fetch markdown source: ${response.status}`); + } + + await writeTextToClipboard(await response.text()); + setCopyMarkdownButtonState(button, "copied"); + window.setTimeout(() => setCopyMarkdownButtonState(button, "ready"), 1800); + } + catch (error) { + console.error(error); + setCopyMarkdownButtonState(button, "failed"); + window.setTimeout(() => setCopyMarkdownButtonState(button, "ready"), 2500); + } +} + +function addCopyMarkdownButton() { + const actionBar = document.querySelector(".content > .actionbar"); + const markdownSourceUrl = getMarkdownSourceUrl(); + + if (!actionBar || !markdownSourceUrl || actionBar.querySelector(`.${copyMarkdownGroupClass}`)) { + return; + } + + const pageActions = document.createElement("div"); + const buttonGroup = document.createElement("div"); + const copyButton = document.createElement("button"); + const toggleButton = document.createElement("button"); + const toggleIcon = document.createElement("i"); + const menu = document.createElement("div"); + const copyMenuItem = createDropdownItem("button", "bi-clipboard", "Copy page", "Copy page as Markdown for LLMs"); + const viewMenuItem = createDropdownItem("a", "bi-markdown", "View as Markdown", "View this page as plain text", "bi-box-arrow-up-right"); + + pageActions.className = "pnp-page-actions d-print-none"; + buttonGroup.className = `btn-group ${copyMarkdownGroupClass}`; + copyButton.type = "button"; + copyButton.className = `btn btn-sm border ${copyMarkdownButtonClass}`; + toggleButton.type = "button"; + toggleButton.className = "btn btn-sm border pnp-copy-markdown-toggle"; + toggleButton.setAttribute("aria-haspopup", "menu"); + toggleButton.setAttribute("aria-expanded", "false"); + toggleButton.setAttribute("aria-label", "Copy page options"); + toggleButton.title = "Copy page options"; + toggleIcon.className = "bi bi-chevron-down"; + toggleIcon.setAttribute("aria-hidden", "true"); + menu.className = `dropdown-menu dropdown-menu-end ${copyMarkdownMenuClass}`; + menu.setAttribute("role", "menu"); + viewMenuItem.href = markdownSourceUrl.href; + viewMenuItem.target = "_blank"; + viewMenuItem.rel = "noopener"; + + setCopyMarkdownButtonState(copyButton, "ready"); + copyButton.addEventListener("click", () => copyMarkdown(markdownSourceUrl, copyButton)); + toggleButton.addEventListener("click", event => { + event.preventDefault(); + event.stopPropagation(); + setCopyMarkdownMenuOpen(buttonGroup, toggleButton, toggleIcon, menu, !menu.classList.contains("show")); + }); + copyMenuItem.addEventListener("click", () => { + setCopyMarkdownMenuOpen(buttonGroup, toggleButton, toggleIcon, menu, false); + copyMarkdown(markdownSourceUrl, copyButton); + }); + viewMenuItem.addEventListener("click", () => setCopyMarkdownMenuOpen(buttonGroup, toggleButton, toggleIcon, menu, false)); + menu.addEventListener("click", event => event.stopPropagation()); + document.addEventListener("click", event => { + if (!buttonGroup.contains(event.target)) { + setCopyMarkdownMenuOpen(buttonGroup, toggleButton, toggleIcon, menu, false); + } + }); + document.addEventListener("keydown", event => { + if (event.key === "Escape") { + setCopyMarkdownMenuOpen(buttonGroup, toggleButton, toggleIcon, menu, false); + } + }); + + toggleButton.appendChild(toggleIcon); + menu.append(copyMenuItem, viewMenuItem); + buttonGroup.append(copyButton, toggleButton, menu); + pageActions.appendChild(buttonGroup); + actionBar.appendChild(pageActions); +} + +function initializeCopyMarkdownButton() { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", addCopyMarkdownButton, { once: true }); + return; + } + + addCopyMarkdownButton(); +} + +initializeCopyMarkdownButton(); + export default { - defaultTheme: "auto" + defaultTheme: "auto", + start() { + initializeCopyMarkdownButton(); + } } \ No newline at end of file