From 1b5b12dcf0e4e479c8ea94ad13264c3183a35d78 Mon Sep 17 00:00:00 2001 From: soyalejolopez <88358406+soyalejolopez@users.noreply.github.com> Date: Thu, 29 Oct 2026 11:36:55 -0500 Subject: [PATCH] Count downloads: the Download button opens a counted download page The button now opens r//download/, which saves the file and logs a Clarity resource_download event. Loads of that page are read from the Clarity URL export and added to resource-stats.json as downloads per resource, kept separate from views. Download pages are noindex and stay out of the sitemap and feed. Moves the Clarity parsing helpers into stats-state.js so the whole export-to-stats path is covered by tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- assets/resource-page.css | 3 + assets/resource-page.js | 12 ++ docs/CATALOG-METADATA.md | 2 +- index.html | 13 +- r/add-engagegroupadmins/download/index.html | 57 +++++++ r/add-engagegroupadmins/index.html | 2 +- .../download/index.html | 57 +++++++ r/agent-365-enablement-checklist/index.html | 2 +- .../download/index.html | 57 +++++++ r/agent-brainstorming-template/index.html | 2 +- r/agents-cost-calculator/download/index.html | 57 +++++++ r/agents-cost-calculator/index.html | 2 +- r/copilot-agents-guide/download/index.html | 57 +++++++ r/copilot-agents-guide/index.html | 2 +- .../download/index.html | 57 +++++++ .../index.html | 2 +- .../download/index.html | 57 +++++++ r/delete-allcommunityposts/index.html | 2 +- r/delete-engagegroups/download/index.html | 57 +++++++ r/delete-engagegroups/index.html | 2 +- r/delete-engageusers/download/index.html | 57 +++++++ r/delete-engageusers/index.html | 2 +- .../download/index.html | 57 +++++++ r/export-copilotinteractions/index.html | 2 +- r/export-engagefiles/download/index.html | 57 +++++++ r/export-engagefiles/index.html | 2 +- .../download/index.html | 57 +++++++ r/export-engagenetworkdata/index.html | 2 +- .../download/index.html | 57 +++++++ r/export-m365copilotreports/index.html | 2 +- .../download/index.html | 57 +++++++ r/export-simpleplacesvisual/index.html | 2 +- r/find-mailboxdelegates/download/index.html | 57 +++++++ r/find-mailboxdelegates/index.html | 2 +- .../download/index.html | 57 +++++++ r/get-allinsightslicensedusers/index.html | 2 +- r/get-engagegroupinfo/download/index.html | 57 +++++++ r/get-engagegroupinfo/index.html | 2 +- .../download/index.html | 57 +++++++ r/get-groupsmembersmanagers/index.html | 2 +- r/get-yammersitesize/download/index.html | 57 +++++++ r/get-yammersitesize/index.html | 2 +- .../download/index.html | 57 +++++++ .../index.html | 2 +- .../download/index.html | 57 +++++++ .../index.html | 2 +- r/preflight-onedrive/download/index.html | 57 +++++++ r/preflight-onedrive/index.html | 2 +- .../download/index.html | 57 +++++++ r/purview-audit-copilot-report/index.html | 2 +- .../download/index.html | 57 +++++++ r/update-vivaengagelicensingtoggle/index.html | 2 +- tools/catalog-build/build-stats.js | 128 +++------------ tools/catalog-build/index.js | 7 +- tools/catalog-build/site-pages.js | 101 +++++++++--- tools/catalog-build/stats-state.js | 151 +++++++++++++++++- tools/catalog-build/test-site-output.js | 25 ++- tools/catalog-build/test-stats-state.js | 76 ++++++++- traffic-data/README.md | 2 +- 60 files changed, 1764 insertions(+), 174 deletions(-) create mode 100644 r/add-engagegroupadmins/download/index.html create mode 100644 r/agent-365-enablement-checklist/download/index.html create mode 100644 r/agent-brainstorming-template/download/index.html create mode 100644 r/agents-cost-calculator/download/index.html create mode 100644 r/copilot-agents-guide/download/index.html create mode 100644 r/copilot-analytics-reports-navigator/download/index.html create mode 100644 r/delete-allcommunityposts/download/index.html create mode 100644 r/delete-engagegroups/download/index.html create mode 100644 r/delete-engageusers/download/index.html create mode 100644 r/export-copilotinteractions/download/index.html create mode 100644 r/export-engagefiles/download/index.html create mode 100644 r/export-engagenetworkdata/download/index.html create mode 100644 r/export-m365copilotreports/download/index.html create mode 100644 r/export-simpleplacesvisual/download/index.html create mode 100644 r/find-mailboxdelegates/download/index.html create mode 100644 r/get-allinsightslicensedusers/download/index.html create mode 100644 r/get-engagegroupinfo/download/index.html create mode 100644 r/get-groupsmembersmanagers/download/index.html create mode 100644 r/get-yammersitesize/download/index.html create mode 100644 r/microsoft-agent-365-customer-implementation-guide/download/index.html create mode 100644 r/microsoft-agent-365-technical-advisory/download/index.html create mode 100644 r/preflight-onedrive/download/index.html create mode 100644 r/purview-audit-copilot-report/download/index.html create mode 100644 r/update-vivaengagelicensingtoggle/download/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 296e8b32..58b12cdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ This file captures only selected high-level milestones. * Added a shareable page for every resource; archived resources are now hidden by default; added an Agent 365 highlight row (driven by the `agent-365` tag) and "New" badges to the landing page. * Cataloged 23 previously unlisted scripts, snippets, and samples (Viva Engage administration, Teams Phone and Rooms, SimpleGraph, ESS extensions) and archived scripts that depend on retired modules or services. * Archived the prompt samples and removed a 41 MB archived demo video. -* Added a **Download** button to resource pages and the landing detail view for resources that are one self-contained file: interactive guides and tools (saved as a single HTML file), single-file scripts, PowerPoint decks, Power BI reports, and the Technical Advisory PDF. Clicks are recorded as a `resource_download` event in Clarity. An optional `download` front matter field picks the file explicitly; see `docs/CATALOG-METADATA.md`. +* Added a **Download** button to resource pages and the landing detail view for resources that are one self-contained file: interactive guides and tools (saved as a single HTML file), single-file scripts, PowerPoint decks, Power BI reports, and the Technical Advisory PDF. The button opens a short download page (`r//download/`) that saves the file and records the download. Loads of that page are counted per resource as `downloads` in `resource-stats.json`, kept separate from views, and logged as a `resource_download` event in Clarity. An optional `download` front matter field picks the file explicitly; see `docs/CATALOG-METADATA.md`. * The daily **Collect Traffic Data** workflow no longer collects GitHub repository traffic (views, clones, referrers), which needs a token that expires every few days under organization policy. It skips that step with a notice and keeps collecting Clarity page analytics and discussion votes. Set the repository variable `COLLECT_GITHUB_TRAFFIC` to `true` and replace `TRAFFIC_TOKEN` to resume. ### April 20, 2018 diff --git a/assets/resource-page.css b/assets/resource-page.css index 9955064b..fb6bf255 100644 --- a/assets/resource-page.css +++ b/assets/resource-page.css @@ -69,6 +69,9 @@ h1 { font-size: clamp(1.75rem, 4vw, 2.75rem); line-height: 1.15; letter-spacing: .btn:hover { text-decoration: none; background: var(--surface-hover); } .btn svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } .btn-size { font-weight: 400; color: var(--text-muted); } +.btn-primary .btn-size { color: inherit; opacity: 0.85; } +.fine { margin-top: 1.25rem; font-size: 0.875rem; color: var(--text-muted); max-width: 46rem; } +.fine a { text-decoration: underline; text-underline-offset: 2px; } .btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } .btn-primary:hover { background: var(--accent); filter: brightness(1.08); } [data-theme="dark"] .btn-primary { color: #14141a; } diff --git a/assets/resource-page.js b/assets/resource-page.js index 4ca6f623..c7416204 100644 --- a/assets/resource-page.js +++ b/assets/resource-page.js @@ -29,6 +29,7 @@ } // Same Clarity project and event names as the catalog so per-resource view stats keep working. + var downloadSlug = document.documentElement.getAttribute('data-download-slug'); var clarityId = script && script.getAttribute('data-clarity'); if (clarityId) { (function (c, l, a, r, i, t, y) { @@ -40,9 +41,20 @@ window.clarity('set', 'page', location.pathname); window.clarity('set', 'resource', slug); window.clarity('event', 'resource_view'); + } else if (downloadSlug) { + // Loading this page is what counts as a download: the stats read its URL from Clarity. + window.clarity('set', 'page', location.pathname); + window.clarity('set', 'resource', downloadSlug); + window.clarity('event', 'resource_download'); } document.querySelectorAll('[data-event]').forEach(function (element) { element.addEventListener('click', function () { window.clarity('event', element.getAttribute('data-event')); }); }); } + + // The download page saves the file by itself; its visible link covers blocked scripts. + var autoDownload = document.querySelector('[data-autodownload]'); + if (autoDownload) { + setTimeout(function () { autoDownload.click(); }, 600); + } })(); diff --git a/docs/CATALOG-METADATA.md b/docs/CATALOG-METADATA.md index f2f4229a..4daa839c 100644 --- a/docs/CATALOG-METADATA.md +++ b/docs/CATALOG-METADATA.md @@ -66,7 +66,7 @@ prerequisites: | `format` | enum | One of `ps1`, `bundle`, `declarative`, `interactive`, `pptx`, `pbix`, or `md`. This replaces the former `artifact` label. | | `interactiveKind` | enum | Only for `format: interactive`. Set `guide` for a walkthrough or implementation/decision guide, or `tool` for a calculator or utility. Defaults to `tool`, which the catalog labels "Interactive tool"; `guide` is labeled "Interactive guide". A `guide` is also cross-listed under **Guides & planning** (see below), while a `tool` is not. | | `preview` | string | Optional relative path (inside the resource folder) to a screenshot the catalog detail page renders above the description, for example `preview.webp`. Use an optimized local image (WebP or PNG). No URLs, absolute paths, `..`, or backslashes. Omit it for resources without a screenshot. | -| `download` | string | Optional relative path (inside the resource folder) of the one file the **Download** button saves, for example `Report.pbix`. No URLs, absolute paths, `..`, or backslashes. Without it, the catalog picks the file itself: `index.html` for `interactive`, the only `.pptx` or `.pbix` in the folder, and the only `.ps1` for `ps1` when the folder has no module, data, or template files next to it. Other resources get no Download button. Archived resources never get one. The button records a Clarity `resource_download` event. | +| `download` | string | Optional relative path (inside the resource folder) of the one file the **Download** button saves, for example `Report.pbix`. No URLs, absolute paths, `..`, or backslashes. Without it, the catalog picks the file itself: `index.html` for `interactive`, the only `.pptx` or `.pbix` in the folder, and the only `.ps1` for `ps1` when the folder has no module, data, or template files next to it. Other resources get no Download button. Archived resources never get one. The button opens the resource's download page (`r//download/`), which saves the file and records the download: each load of that page is counted in `resource-stats.json` as `downloads`, and Clarity logs a `resource_download` event. | | `featured` | boolean | Use sparingly for resources selected for catalog promotion. Default is `false`. | | `status` | enum | `active`, `preview`, or `archived`. Default is `active`. `archived` resources are hidden from the default catalog view and the sitemap (visitors can reveal them with **Show archived**), and their pages are marked `noindex`. `preview` resources get a Preview badge. | | `url` | HTTPS URL | Optional GitHub or destination URL. When omitted, the generator derives a GitHub URL from the resource path. | diff --git a/index.html b/index.html index a0841f93..de804e80 100644 --- a/index.html +++ b/index.html @@ -1572,7 +1572,7 @@

Related resources

View - @@ -2704,19 +2704,18 @@

Browse the scripts folder on GitHub →

viewBtn.onclick = null; } - // Download button: only for resources with one self-sufficient file (see tools/catalog-build/downloads.js) + // Download button: only for resources with one self-sufficient file (see tools/catalog-build/downloads.js). + // It opens the resource's counted download page, which saves the file and records the download. const downloadBtn = document.getElementById('btnDetailDownload'); if (resource.download) { const size = formatBytes(resource.downloadSize); - downloadBtn.href = resource.download.split('/').map(encodeURIComponent).join('/'); - downloadBtn.setAttribute('download', resource.downloadName || ''); + downloadBtn.href = `r/${encodeURIComponent(resource.slug)}/download/`; downloadBtn.setAttribute('aria-label', `Download ${resource.downloadName || resource.name}${size ? ` (${size})` : ''}`); document.getElementById('detailDownloadSize').textContent = size; downloadBtn.style.display = ''; } else { downloadBtn.style.display = 'none'; downloadBtn.removeAttribute('href'); - downloadBtn.removeAttribute('download'); downloadBtn.removeAttribute('aria-label'); document.getElementById('detailDownloadSize').textContent = ''; } @@ -2808,10 +2807,6 @@

Browse the scripts folder on GitHub →

return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } - document.getElementById('btnDetailDownload').addEventListener('click', () => { - if (typeof window.clarity !== 'undefined') { window.clarity('event', 'resource_download'); } - }); - function openViewer(name, url) { const overlay = document.getElementById('viewerOverlay'); const frame = document.getElementById('viewerFrame'); diff --git a/r/add-engagegroupadmins/download/index.html b/r/add-engagegroupadmins/download/index.html new file mode 100644 index 00000000..b04d23ba --- /dev/null +++ b/r/add-engagegroupadmins/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Add-EngageGroupAdmins.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Add-EngageGroupAdmins: Add-EngageGroupAdmins.ps1 (7 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/add-engagegroupadmins/index.html b/r/add-engagegroupadmins/index.html index 74d474a8..578626de 100644 --- a/r/add-engagegroupadmins/index.html +++ b/r/add-engagegroupadmins/index.html @@ -54,7 +54,7 @@

Add-EngageGroupAdmins

Bulk-add owners (admins) to Viva Engage communities from a CSV by using the Microsoft Graph group owners API.

diff --git a/r/agent-365-enablement-checklist/download/index.html b/r/agent-365-enablement-checklist/download/index.html new file mode 100644 index 00000000..c68f255f --- /dev/null +++ b/r/agent-365-enablement-checklist/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download agent-365-enablement-checklist.html | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Agent 365 Enablement Checklist: agent-365-enablement-checklist.html (667 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/agent-365-enablement-checklist/index.html b/r/agent-365-enablement-checklist/index.html index 159a8bde..5e663d07 100644 --- a/r/agent-365-enablement-checklist/index.html +++ b/r/agent-365-enablement-checklist/index.html @@ -55,7 +55,7 @@

Agent 365 Enablement Checklist

diff --git a/r/agent-brainstorming-template/download/index.html b/r/agent-brainstorming-template/download/index.html new file mode 100644 index 00000000..6fcee5f6 --- /dev/null +++ b/r/agent-brainstorming-template/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Copilot Agent Brainstorming.pptx | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Agent Brainstorming Template: Copilot Agent Brainstorming.pptx (401 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/agent-brainstorming-template/index.html b/r/agent-brainstorming-template/index.html index c99b3016..38212a60 100644 --- a/r/agent-brainstorming-template/index.html +++ b/r/agent-brainstorming-template/index.html @@ -54,7 +54,7 @@

Agent Brainstorming Template

Map an agent’s type, user flow, knowledge sources, tools, and target problem before implementation.

diff --git a/r/agents-cost-calculator/download/index.html b/r/agents-cost-calculator/download/index.html new file mode 100644 index 00000000..7f87ed1b --- /dev/null +++ b/r/agents-cost-calculator/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download agents-cost-calculator.html | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Agents Cost Calculator: agents-cost-calculator.html (71 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/agents-cost-calculator/index.html b/r/agents-cost-calculator/index.html index 28c49bb6..97272221 100644 --- a/r/agents-cost-calculator/index.html +++ b/r/agents-cost-calculator/index.html @@ -55,7 +55,7 @@

Agents Cost Calculator

diff --git a/r/copilot-agents-guide/download/index.html b/r/copilot-agents-guide/download/index.html new file mode 100644 index 00000000..236a1398 --- /dev/null +++ b/r/copilot-agents-guide/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download copilot-agents-guide.html | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Copilot Agents Guide: copilot-agents-guide.html (205 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/copilot-agents-guide/index.html b/r/copilot-agents-guide/index.html index 276fe01d..2982cb9e 100644 --- a/r/copilot-agents-guide/index.html +++ b/r/copilot-agents-guide/index.html @@ -55,7 +55,7 @@

Copilot Agents Guide

diff --git a/r/copilot-analytics-reports-navigator/download/index.html b/r/copilot-analytics-reports-navigator/download/index.html new file mode 100644 index 00000000..c575c925 --- /dev/null +++ b/r/copilot-analytics-reports-navigator/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download copilot-analytics-reports-navigator.html | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Copilot Analytics Reports Navigator: copilot-analytics-reports-navigator.html (208 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/copilot-analytics-reports-navigator/index.html b/r/copilot-analytics-reports-navigator/index.html index e420155e..a6ea2666 100644 --- a/r/copilot-analytics-reports-navigator/index.html +++ b/r/copilot-analytics-reports-navigator/index.html @@ -55,7 +55,7 @@

Copilot Analytics Reports Navigator

diff --git a/r/delete-allcommunityposts/download/index.html b/r/delete-allcommunityposts/download/index.html new file mode 100644 index 00000000..9e66d52e --- /dev/null +++ b/r/delete-allcommunityposts/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Delete-AllCommunityPosts.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Delete-AllCommunityPosts: Delete-AllCommunityPosts.ps1 (11 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/delete-allcommunityposts/index.html b/r/delete-allcommunityposts/index.html index bf9273fa..1a4df79d 100644 --- a/r/delete-allcommunityposts/index.html +++ b/r/delete-allcommunityposts/index.html @@ -54,7 +54,7 @@

Delete-AllCommunityPosts

Delete every message in a Viva Engage community; dry run by default, optional CSV backup, and deletion can't be undone.

diff --git a/r/delete-engagegroups/download/index.html b/r/delete-engagegroups/download/index.html new file mode 100644 index 00000000..0c4a1291 --- /dev/null +++ b/r/delete-engagegroups/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Delete-EngageGroups.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Delete-EngageGroups: Delete-EngageGroups.ps1 (6 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/delete-engagegroups/index.html b/r/delete-engagegroups/index.html index a7836ae5..09eb4bbd 100644 --- a/r/delete-engagegroups/index.html +++ b/r/delete-engagegroups/index.html @@ -54,7 +54,7 @@

Delete-EngageGroups

Bulk-delete Viva Engage communities from a CSV through Microsoft Graph; dry run by default and deletion can't be undone.

diff --git a/r/delete-engageusers/download/index.html b/r/delete-engageusers/download/index.html new file mode 100644 index 00000000..5eada4d7 --- /dev/null +++ b/r/delete-engageusers/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Delete-EngageUsers.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Delete-EngageUsers: Delete-EngageUsers.ps1 (8 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/delete-engageusers/index.html b/r/delete-engageusers/index.html index ff3d72fa..74b22ffa 100644 --- a/r/delete-engageusers/index.html +++ b/r/delete-engageusers/index.html @@ -54,7 +54,7 @@

Delete-EngageUsers

Bulk-remove users from Viva Engage communities from a CSV through Microsoft Graph; dry run by default, accounts are not deleted.

diff --git a/r/export-copilotinteractions/download/index.html b/r/export-copilotinteractions/download/index.html new file mode 100644 index 00000000..45cc5de6 --- /dev/null +++ b/r/export-copilotinteractions/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Export-CopilotInteractions.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Export-CopilotInteractions: Export-CopilotInteractions.ps1 (39 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/export-copilotinteractions/index.html b/r/export-copilotinteractions/index.html index fe1278ec..7951dd3d 100644 --- a/r/export-copilotinteractions/index.html +++ b/r/export-copilotinteractions/index.html @@ -54,7 +54,7 @@

Export-CopilotInteractions

Export user-level Copilot prompts and responses from Microsoft Graph into analysis-ready CSV datasets.

diff --git a/r/export-engagefiles/download/index.html b/r/export-engagefiles/download/index.html new file mode 100644 index 00000000..99eb8d11 --- /dev/null +++ b/r/export-engagefiles/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Export-EngageFiles.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Export-EngageFiles: Export-EngageFiles.ps1 (12 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/export-engagefiles/index.html b/r/export-engagefiles/index.html index 0489ed49..7d5dd094 100644 --- a/r/export-engagefiles/index.html +++ b/r/export-engagefiles/index.html @@ -54,7 +54,7 @@

Export-EngageFiles

Export files uploaded to a Viva Engage network during a date range by using the Yammer Files Export API.

diff --git a/r/export-engagenetworkdata/download/index.html b/r/export-engagenetworkdata/download/index.html new file mode 100644 index 00000000..3b432dc7 --- /dev/null +++ b/r/export-engagenetworkdata/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Export-EngageNetworkData.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Export-EngageNetworkData: Export-EngageNetworkData.ps1 (9 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/export-engagenetworkdata/index.html b/r/export-engagenetworkdata/index.html index b80ef405..2f0be9ff 100644 --- a/r/export-engagenetworkdata/index.html +++ b/r/export-engagenetworkdata/index.html @@ -54,7 +54,7 @@

Export-EngageNetworkData

Export Viva Engage network messages and files for a date range by using the Network Data Export API.

diff --git a/r/export-m365copilotreports/download/index.html b/r/export-m365copilotreports/download/index.html new file mode 100644 index 00000000..fbca073c --- /dev/null +++ b/r/export-m365copilotreports/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Export-M365CopilotReports.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Export-M365CopilotReports: Export-M365CopilotReports.ps1 (33 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/export-m365copilotreports/index.html b/r/export-m365copilotreports/index.html index 5dc79482..9e1780b1 100644 --- a/r/export-m365copilotreports/index.html +++ b/r/export-m365copilotreports/index.html @@ -54,7 +54,7 @@

Export-M365CopilotReports

Export Entra users, Purview Copilot audit events, all audit interactions, or Microsoft 365 Copilot usage reports.

diff --git a/r/export-simpleplacesvisual/download/index.html b/r/export-simpleplacesvisual/download/index.html new file mode 100644 index 00000000..eb017a4b --- /dev/null +++ b/r/export-simpleplacesvisual/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Export-SimplePlacesVisual.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Export-SimplePlacesVisual: Export-SimplePlacesVisual.ps1 (12 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/export-simpleplacesvisual/index.html b/r/export-simpleplacesvisual/index.html index 970577c2..5fafbbc7 100644 --- a/r/export-simpleplacesvisual/index.html +++ b/r/export-simpleplacesvisual/index.html @@ -54,7 +54,7 @@

Export-SimplePlacesVisual

Print or save a text tree of the Microsoft Places directory of buildings, floors, sections, rooms, spaces, and desks.

diff --git a/r/find-mailboxdelegates/download/index.html b/r/find-mailboxdelegates/download/index.html new file mode 100644 index 00000000..d2c7e552 --- /dev/null +++ b/r/find-mailboxdelegates/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Find-MailboxDelegates.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Find-MailboxDelegates: Find-MailboxDelegates.ps1 (62 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/find-mailboxdelegates/index.html b/r/find-mailboxdelegates/index.html index 38dc9eed..99d2c33e 100644 --- a/r/find-mailboxdelegates/index.html +++ b/r/find-mailboxdelegates/index.html @@ -54,7 +54,7 @@

Find-MailboxDelegates

Collect Exchange mailbox permissions and build migration batches that keep delegates together for hybrid migrations.

diff --git a/r/get-allinsightslicensedusers/download/index.html b/r/get-allinsightslicensedusers/download/index.html new file mode 100644 index 00000000..9ef35216 --- /dev/null +++ b/r/get-allinsightslicensedusers/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Get-AllInsightsLicensedUsers.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Get-AllInsightsLicensedUsers: Get-AllInsightsLicensedUsers.ps1 (8 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/get-allinsightslicensedusers/index.html b/r/get-allinsightslicensedusers/index.html index 14836b33..5c29b161 100644 --- a/r/get-allinsightslicensedusers/index.html +++ b/r/get-allinsightslicensedusers/index.html @@ -54,7 +54,7 @@

Get-AllInsightsLicensedUsers

Export Entra users licensed for Viva Insights, with manager and department, to build the organizational data file.

diff --git a/r/get-engagegroupinfo/download/index.html b/r/get-engagegroupinfo/download/index.html new file mode 100644 index 00000000..c6e9b327 --- /dev/null +++ b/r/get-engagegroupinfo/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Get-EngageGroupInfo.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Get-EngageGroupInfo: Get-EngageGroupInfo.ps1 (8 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/get-engagegroupinfo/index.html b/r/get-engagegroupinfo/index.html index 3235e16e..92b941d1 100644 --- a/r/get-engagegroupinfo/index.html +++ b/r/get-engagegroupinfo/index.html @@ -54,7 +54,7 @@

Get-EngageGroupInfo

Report every Viva Engage community's IDs, member count, and admins to CSV by using Microsoft Graph.

diff --git a/r/get-groupsmembersmanagers/download/index.html b/r/get-groupsmembersmanagers/download/index.html new file mode 100644 index 00000000..0d880489 --- /dev/null +++ b/r/get-groupsmembersmanagers/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Get-GroupsMembersManagers.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Get-GroupsMembersManagers: Get-GroupsMembersManagers.ps1 (12 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/get-groupsmembersmanagers/index.html b/r/get-groupsmembersmanagers/index.html index 9e7870b8..37d48171 100644 --- a/r/get-groupsmembersmanagers/index.html +++ b/r/get-groupsmembersmanagers/index.html @@ -54,7 +54,7 @@

Get-GroupsMembersManagers

Export Entra group members with manager and profile details to CSV for Viva Insights organizational data uploads.

diff --git a/r/get-yammersitesize/download/index.html b/r/get-yammersitesize/download/index.html new file mode 100644 index 00000000..a1413394 --- /dev/null +++ b/r/get-yammersitesize/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Get-YammerSiteSize.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Get-YammerSiteSize: Get-YammerSiteSize.ps1 (3 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/get-yammersitesize/index.html b/r/get-yammersitesize/index.html index 8014ecf5..3bacaa71 100644 --- a/r/get-yammersitesize/index.html +++ b/r/get-yammersitesize/index.html @@ -54,7 +54,7 @@

Get-YammerSiteSize

Report the SharePoint storage used by every site created through Viva Engage (Yammer) communities.

diff --git a/r/microsoft-agent-365-customer-implementation-guide/download/index.html b/r/microsoft-agent-365-customer-implementation-guide/download/index.html new file mode 100644 index 00000000..7363bb81 --- /dev/null +++ b/r/microsoft-agent-365-customer-implementation-guide/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download microsoft-agent-365-customer-implementation-guide.html | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Microsoft Agent 365 Customer Implementation Guide: microsoft-agent-365-customer-implementation-guide.html (2.3 MB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/microsoft-agent-365-customer-implementation-guide/index.html b/r/microsoft-agent-365-customer-implementation-guide/index.html index 0d1968a8..cbe1a7a3 100644 --- a/r/microsoft-agent-365-customer-implementation-guide/index.html +++ b/r/microsoft-agent-365-customer-implementation-guide/index.html @@ -55,7 +55,7 @@

Microsoft Agent 365 Customer Implementation Guide

diff --git a/r/microsoft-agent-365-technical-advisory/download/index.html b/r/microsoft-agent-365-technical-advisory/download/index.html new file mode 100644 index 00000000..e6be7074 --- /dev/null +++ b/r/microsoft-agent-365-technical-advisory/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Agent-365-Technical-Advisory.pdf | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Microsoft Agent 365 Technical Advisory: Agent-365-Technical-Advisory.pdf (817 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/microsoft-agent-365-technical-advisory/index.html b/r/microsoft-agent-365-technical-advisory/index.html index 53d1f5ee..71392bec 100644 --- a/r/microsoft-agent-365-technical-advisory/index.html +++ b/r/microsoft-agent-365-technical-advisory/index.html @@ -55,7 +55,7 @@

Microsoft Agent 365 Technical Advisory

diff --git a/r/preflight-onedrive/download/index.html b/r/preflight-onedrive/download/index.html new file mode 100644 index 00000000..c0d304fb --- /dev/null +++ b/r/preflight-onedrive/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Preflight-OneDrive.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Preflight-OneDrive: Preflight-OneDrive.ps1 (130 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/preflight-onedrive/index.html b/r/preflight-onedrive/index.html index f50977ce..f33d0d24 100644 --- a/r/preflight-onedrive/index.html +++ b/r/preflight-onedrive/index.html @@ -54,7 +54,7 @@

Preflight-OneDrive

Scan a local or network folder for potential OneDrive for Business sync issues and optionally generate an HTML report.

diff --git a/r/purview-audit-copilot-report/download/index.html b/r/purview-audit-copilot-report/download/index.html new file mode 100644 index 00000000..e2ee778e --- /dev/null +++ b/r/purview-audit-copilot-report/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download Copilot_Audit_PBI.pbix | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Purview Audit Copilot Report: Copilot_Audit_PBI.pbix (2.0 MB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/purview-audit-copilot-report/index.html b/r/purview-audit-copilot-report/index.html index b36d4886..9070c55c 100644 --- a/r/purview-audit-copilot-report/index.html +++ b/r/purview-audit-copilot-report/index.html @@ -54,7 +54,7 @@

Purview Audit Copilot Report

Analyze Copilot adoption by user, app, agent, department, and trend using Purview audit and Entra export data.

diff --git a/r/update-vivaengagelicensingtoggle/download/index.html b/r/update-vivaengagelicensingtoggle/download/index.html new file mode 100644 index 00000000..f8aea072 --- /dev/null +++ b/r/update-vivaengagelicensingtoggle/download/index.html @@ -0,0 +1,57 @@ + + + + + + Download BlockUsersWithoutLicense.ps1 | Microsoft FastTrack + + + + + + + + + + + + + +
+ + +
+
Download
+

Your download is starting

+

Update-VivaEngageLicensingToggle: BlockUsersWithoutLicense.ps1 (3 KB)

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + + + + diff --git a/r/update-vivaengagelicensingtoggle/index.html b/r/update-vivaengagelicensingtoggle/index.html index 4c8f48db..3d3d2da3 100644 --- a/r/update-vivaengagelicensingtoggle/index.html +++ b/r/update-vivaengagelicensingtoggle/index.html @@ -54,7 +54,7 @@

Update-VivaEngageLicensingToggle

Check or irreversibly enforce per-user licensing for a Viva Engage network through the Viva Engage REST endpoint.

diff --git a/tools/catalog-build/build-stats.js b/tools/catalog-build/build-stats.js index d4b71fc5..7b1d9f07 100644 --- a/tools/catalog-build/build-stats.js +++ b/tools/catalog-build/build-stats.js @@ -1,7 +1,14 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { replaceClarityDays, slugFromClarityUrl, unchangedStatsPayload } from './stats-state.js'; +import { + collectClarityTotals, + numberFrom, + parseClarityTraffic, + replaceClarityDays, + subtractClarityTotals, + unchangedStatsPayload +} from './stats-state.js'; const toolDirectory = dirname(fileURLToPath(import.meta.url)); const repositoryRoot = join(toolDirectory, '..', '..'); @@ -32,11 +39,6 @@ function readOptionalJson(path, fallback) { } } -function numberFrom(value) { - const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value; - return Number.isFinite(number) && number >= 0 ? number : undefined; -} - function logicalRepoPath(pathname) { // Strip the `/owner/repo/(tree|blob)//` prefix so `/tree/` folder views and // `/blob/` file views normalize to the same repo-relative path. Repo-root and @@ -98,67 +100,6 @@ function collectFallbackViews(resourceFolders) { return totals; } -function dateFromClarityRow(row) { - const value = row.date ?? row.Date ?? row.day ?? row.Day; - if (typeof value !== 'string') return undefined; - const match = value.match(/^\d{4}-\d{2}-\d{2}/); - return match?.[0]; -} - -function metricsFromClarityRow(row) { - const explicitViews = numberFrom( - row.pageViews ?? row.pageviews ?? row.PageViews ?? row.views ?? - row.totalScreenCount ?? row.visitsCount - ); - const sessions = numberFrom(row.totalSessionCount ?? row.sessions ?? row.sessionCount); - const pagesPerSession = numberFrom( - row.pagesPerSession ?? row.PagesPerSession ?? row.PagesPerSessionPercentage - ); - const views = explicitViews ?? ( - sessions !== undefined && pagesPerSession !== undefined - ? sessions * pagesPerSession - : sessions - ); - const uniques = numberFrom( - row.distinctUserCount ?? row.distantUserCount ?? row.uniqueUserCount ?? - row.uniqueUsers ?? row.users - ); - return { - ...(views !== undefined ? { views: Math.round(views) } : {}), - ...(uniques !== undefined ? { uniques: Math.round(uniques) } : {}) - }; -} - -function parseClarityTraffic(data, knownSlugs) { - const traffic = Array.isArray(data) - ? data.find(metric => metric?.metricName === 'Traffic') - : undefined; - if (!traffic || !Array.isArray(traffic.information)) { - throw new Error('Clarity response did not include the Traffic metric'); - } - const totals = new Map(); - const dated = new Map(); - - for (const row of traffic.information) { - const slug = slugFromClarityUrl(row.URL ?? row.Url ?? row.url, knownSlugs); - if (!slug) continue; - const metrics = metricsFromClarityRow(row); - if (metrics.views === undefined) continue; - - const date = dateFromClarityRow(row); - const target = date - ? (dated.get(date) ?? new Map()) - : totals; - const current = target.get(slug) ?? { views: 0 }; - current.views += metrics.views; - if (metrics.uniques !== undefined) current.uniques = (current.uniques ?? 0) + metrics.uniques; - target.set(slug, current); - if (date) dated.set(date, target); - } - - return { totals, dated }; -} - async function fetchClarity(days, knownSlugs) { const url = new URL('https://www.clarity.ms/export-data/api/v1/project-live-insights'); url.searchParams.set('numOfDays', String(days)); @@ -173,25 +114,6 @@ async function fetchClarity(days, knownSlugs) { return parseClarityTraffic(await response.json(), knownSlugs); } -function subtractClarityTotals(larger, smaller) { - const result = new Map(); - for (const [slug, metrics] of larger) { - const previous = smaller.get(slug); - const views = metrics.views - (previous?.views ?? 0); - if (views < 0) { - console.warn(`Warning: Clarity returned a negative window difference for ${slug}; skipping it.`); - continue; - } - const entry = { views: Math.round(views) }; - if (metrics.uniques !== undefined && previous?.uniques !== undefined) { - const uniques = metrics.uniques - previous.uniques; - if (uniques >= 0) entry.uniques = Math.round(uniques); - } - result.set(slug, entry); - } - return result; -} - function setActionOutput(name, value) { if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); @@ -236,24 +158,6 @@ async function updateClarityState(state, knownSlugs) { } } -function collectClarityViews(state, knownSlugs) { - const totals = new Map(); - for (const bucket of Object.values(state.days)) { - if (!bucket || typeof bucket !== 'object') continue; - for (const [slug, metrics] of Object.entries(bucket)) { - if (!knownSlugs.has(slug) || !metrics || typeof metrics !== 'object') continue; - const views = numberFrom(metrics.views); - if (views === undefined) continue; - const total = totals.get(slug) ?? { views: 0 }; - total.views += Math.round(views); - const uniques = numberFrom(metrics.uniques); - if (uniques !== undefined) total.viewsUniques = (total.viewsUniques ?? 0) + Math.round(uniques); - totals.set(slug, total); - } - } - return totals; -} - async function githubGraphql(query, variables) { const response = await fetch('https://api.github.com/graphql', { method: 'POST', @@ -382,7 +286,7 @@ const clarityState = readOptionalJson(clarityStatePath, { lastRun: '', days: {} if (!clarityState.days || typeof clarityState.days !== 'object') clarityState.days = {}; const clarityResult = await updateClarityState(clarityState, knownSlugs); -const clarityViews = collectClarityViews(clarityState, knownSlugs); +const clarityViews = collectClarityTotals(clarityState, knownSlugs); const fallbackViews = collectFallbackViews(resourceFolders); const discussionResult = await collectDiscussions(); const upvoteTotals = discussionResult.available @@ -390,6 +294,7 @@ const upvoteTotals = discussionResult.available : previousUpvotes(previousStats, knownSlugs); const resources = {}; let viewCount = 0; +let downloadCount = 0; let upvoteCount = 0; for (const resource of catalog.resources) { @@ -399,12 +304,20 @@ for (const resource of catalog.resources) { // Clarity is authoritative once it has a nonzero cumulative total; legacy GitHub // path traffic only prevents existing resources from losing views during migration. - const views = clarity?.views > 0 ? clarity : fallback; + const views = clarity?.views > 0 + ? { views: clarity.views, ...(clarity.viewsUniques !== undefined ? { viewsUniques: clarity.viewsUniques } : {}) } + : fallback; if (views) { Object.assign(stats, views); viewCount += 1; } + // Loads of the counted download page (r//download/). + if (clarity?.downloads > 0) { + stats.downloads = clarity.downloads; + downloadCount += 1; + } + const upvotes = upvoteTotals.get(resource.slug); if (upvotes) { Object.assign(stats, upvotes); @@ -417,6 +330,7 @@ for (const resource of catalog.resources) { const payload = { sources: { views: 'clarity', + downloads: 'clarity', upvotes: 'github-discussions' }, resources @@ -437,7 +351,7 @@ if (!checkOnly) { } } -console.log(`${checkOnly ? 'Checked' : 'Wrote'} real stats for ${viewCount} resource${viewCount === 1 ? '' : 's'} with views and ${upvoteCount} resource${upvoteCount === 1 ? '' : 's'} with upvotes.`); +console.log(`${checkOnly ? 'Checked' : 'Wrote'} real stats for ${viewCount} resource${viewCount === 1 ? '' : 's'} with views, ${downloadCount} with downloads and ${upvoteCount} resource${upvoteCount === 1 ? '' : 's'} with upvotes.`); setActionOutput('generated', 'true'); setActionOutput('clarity_available', String(clarityResult.available)); diff --git a/tools/catalog-build/index.js b/tools/catalog-build/index.js index f307b84c..35f9b0ea 100644 --- a/tools/catalog-build/index.js +++ b/tools/catalog-build/index.js @@ -4,7 +4,7 @@ import { dirname, join, relative, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import matter from 'gray-matter'; import * as yaml from 'js-yaml'; -import { renderFeed, renderResourcePage, renderSitemap, resourcePath } from './site-pages.js'; +import { renderDownloadPage, renderFeed, renderResourcePage, renderSitemap, resourcePath } from './site-pages.js'; import { downloadName, fileExists, findDownloadFile } from './downloads.js'; const toolDirectory = dirname(fileURLToPath(import.meta.url)); @@ -283,6 +283,10 @@ function writeSiteOutputs(sortedResources) { const directory = join(pagesDirectory, resource.slug); mkdirSync(directory, { recursive: true }); writeFileSync(join(directory, 'index.html'), renderResourcePage(resource, { resources: sortedResources, repositoryRoot })); + if (resource.download) { + mkdirSync(join(directory, 'download'), { recursive: true }); + writeFileSync(join(directory, 'download', 'index.html'), renderDownloadPage(resource)); + } } writeFileSync(join(repositoryRoot, 'sitemap.xml'), renderSitemap(sortedResources)); writeFileSync(join(repositoryRoot, 'feed.xml'), renderFeed(sortedResources)); @@ -298,6 +302,7 @@ if (errors.length > 0) { // Rendering every page in memory proves the templates cope with all current metadata. for (const resource of resources) { renderResourcePage(resource, { resources, repositoryRoot }); + if (resource.download) renderDownloadPage(resource); } console.log(`Catalog metadata is valid for ${resources.length} resources.`); } else { diff --git a/tools/catalog-build/site-pages.js b/tools/catalog-build/site-pages.js index 4ef8c1eb..51656a2e 100644 --- a/tools/catalog-build/site-pages.js +++ b/tools/catalog-build/site-pages.js @@ -167,6 +167,31 @@ function section(title, body) { return body ? `

${title}

${body}
` : ''; } +function siteHeader(root) { + return ``; +} + +function siteFooter(root) { + return ``; +} + export function renderResourcePage(resource, { resources, repositoryRoot }) { const slug = resource.slug; const canonical = `${SITE_URL}${resourcePath(resource)}`; @@ -207,7 +232,7 @@ export function renderResourcePage(resource, { resources, repositoryRoot }) { const downloadSize = formatBytes(resource.downloadSize); const downloadButton = resource.download ? ` - Download${downloadSize ? `${downloadSize}` : ''}` + Download${downloadSize ? `${downloadSize}` : ''}` : ''; const relatedHtml = related.length ? ` @@ -249,19 +274,7 @@ export function renderResourcePage(resource, { resources, repositoryRoot }) { - + ${siteHeader('../../')}
- + ${siteFooter('../../')} `; } +// The Download button opens this page, so every download is a countable page load that +// reaches resource-stats.json through the Clarity URL export. It starts the file download +// by itself and always shows a link to the same file for when scripts are blocked. +export function renderDownloadPage(resource) { + const slug = resource.slug; + const canonical = `${SITE_URL}${resourcePath(resource)}`; + const size = formatBytes(resource.downloadSize); + const fileHref = `../../../${encodePath(resource.download)}`; + const title = `Download ${resource.downloadName} | Microsoft FastTrack`; + + return ` + + + + + ${escapeHtml(title)} + + + + + + + + + + + + ${siteHeader('../../../')} + +
+ + +
+
Download
+

Your download is starting

+

${escapeHtml(resource.title)}: ${escapeHtml(resource.downloadName)}${size ? ` (${size})` : ''}

+ +

If the file does not save automatically, select Download again. Provided as-is under the MIT License; review it before you use it in your tenant.

+
+
+ + ${siteFooter('../../../')} + + + +`; +} + const xml = value => String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character])); diff --git a/tools/catalog-build/stats-state.js b/tools/catalog-build/stats-state.js index 457cd7d8..5c8e7edb 100644 --- a/tools/catalog-build/stats-state.js +++ b/tools/catalog-build/stats-state.js @@ -1,5 +1,13 @@ +const CLARITY_METRICS = ['views', 'uniques', 'downloads']; + +export function numberFrom(value) { + const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value; + return Number.isFinite(number) && number >= 0 ? number : undefined; +} + // Resolves a Clarity page URL to a catalog slug. The catalog's detail view uses // `/#` and each shareable resource page lives at `/r//`; both count as views. +// A resource's counted download page (`/r//download/`) is not a view. export function slugFromClarityUrl(value, knownSlugs) { if (typeof value !== 'string') return undefined; try { @@ -17,6 +25,147 @@ export function slugFromClarityUrl(value, knownSlugs) { } } +// Resolves the counted download page of a resource (`/r//download/`). Each load of +// that page is one download, which is how downloads reach resource-stats.json. +export function downloadSlugFromClarityUrl(value, knownSlugs) { + if (typeof value !== 'string') return undefined; + try { + const match = new URL(value).pathname.match(/\/r\/([^/]+)\/download\/?$/); + if (!match) return undefined; + const slug = decodeURIComponent(match[1]); + return knownSlugs.has(slug) ? slug : undefined; + } catch { + return undefined; + } +} + +function orderedMetrics(metrics) { + return Object.fromEntries( + CLARITY_METRICS.filter(metric => metrics[metric] !== undefined).map(metric => [metric, metrics[metric]]) + ); +} + +function orderedBucket(bucket) { + return new Map([...bucket].map(([slug, metrics]) => [slug, orderedMetrics(metrics)])); +} + +function dateFromClarityRow(row) { + const value = row.date ?? row.Date ?? row.day ?? row.Day; + if (typeof value !== 'string') return undefined; + const match = value.match(/^\d{4}-\d{2}-\d{2}/); + return match?.[0]; +} + +function metricsFromClarityRow(row) { + const explicitViews = numberFrom( + row.pageViews ?? row.pageviews ?? row.PageViews ?? row.views ?? + row.totalScreenCount ?? row.visitsCount + ); + const sessions = numberFrom(row.totalSessionCount ?? row.sessions ?? row.sessionCount); + const pagesPerSession = numberFrom( + row.pagesPerSession ?? row.PagesPerSession ?? row.PagesPerSessionPercentage + ); + const views = explicitViews ?? ( + sessions !== undefined && pagesPerSession !== undefined + ? sessions * pagesPerSession + : sessions + ); + const uniques = numberFrom( + row.distinctUserCount ?? row.distantUserCount ?? row.uniqueUserCount ?? + row.uniqueUsers ?? row.users + ); + return { + ...(views !== undefined ? { views: Math.round(views) } : {}), + ...(uniques !== undefined ? { uniques: Math.round(uniques) } : {}) + }; +} + +// Turns a Clarity export (URL dimension) into per-resource metrics: page views of a +// resource's pages, and loads of its download page counted as downloads. +export function parseClarityTraffic(data, knownSlugs) { + const traffic = Array.isArray(data) + ? data.find(metric => metric?.metricName === 'Traffic') + : undefined; + if (!traffic || !Array.isArray(traffic.information)) { + throw new Error('Clarity response did not include the Traffic metric'); + } + const totals = new Map(); + const dated = new Map(); + + for (const row of traffic.information) { + const url = row.URL ?? row.Url ?? row.url; + const viewSlug = slugFromClarityUrl(url, knownSlugs); + const downloadSlug = viewSlug ? undefined : downloadSlugFromClarityUrl(url, knownSlugs); + const slug = viewSlug ?? downloadSlug; + if (!slug) continue; + const metrics = metricsFromClarityRow(row); + if (metrics.views === undefined) continue; + + const date = dateFromClarityRow(row); + const target = date + ? (dated.get(date) ?? new Map()) + : totals; + const current = target.get(slug) ?? {}; + if (downloadSlug) { + current.downloads = (current.downloads ?? 0) + metrics.views; + } else { + current.views = (current.views ?? 0) + metrics.views; + if (metrics.uniques !== undefined) current.uniques = (current.uniques ?? 0) + metrics.uniques; + } + target.set(slug, current); + if (date) dated.set(date, target); + } + + return { + totals: orderedBucket(totals), + dated: new Map([...dated].map(([date, bucket]) => [date, orderedBucket(bucket)])) + }; +} + +// Cumulative 1/2/3-day windows are differenced into daily buckets. +export function subtractClarityTotals(larger, smaller) { + const result = new Map(); + for (const [slug, metrics] of larger) { + const previous = smaller.get(slug); + const entry = {}; + for (const metric of ['views', 'downloads']) { + if (metrics[metric] === undefined) continue; + const difference = metrics[metric] - (previous?.[metric] ?? 0); + if (difference < 0) { + console.warn(`Warning: Clarity returned a negative ${metric} window difference for ${slug}; skipping it.`); + continue; + } + entry[metric] = Math.round(difference); + } + if (entry.views !== undefined && metrics.uniques !== undefined && previous?.uniques !== undefined) { + const uniques = metrics.uniques - previous.uniques; + if (uniques >= 0) entry.uniques = Math.round(uniques); + } + if (Object.keys(entry).length > 0) result.set(slug, orderedMetrics(entry)); + } + return result; +} + +// Sums the persisted daily buckets into per-resource totals. +export function collectClarityTotals(state, knownSlugs) { + const totals = new Map(); + for (const bucket of Object.values(state.days)) { + if (!bucket || typeof bucket !== 'object') continue; + for (const [slug, metrics] of Object.entries(bucket)) { + if (!knownSlugs.has(slug) || !metrics || typeof metrics !== 'object') continue; + const total = totals.get(slug) ?? {}; + for (const [metric, key] of [['views', 'views'], ['uniques', 'viewsUniques'], ['downloads', 'downloads']]) { + const value = numberFrom(metrics[metric]); + if (value !== undefined) total[key] = (total[key] ?? 0) + Math.round(value); + } + totals.set(slug, total); + } + } + return new Map([...totals].map(([slug, total]) => [slug, Object.fromEntries( + ['views', 'viewsUniques', 'downloads'].filter(key => total[key] !== undefined).map(key => [key, total[key]]) + )])); +} + export function replaceClarityDays(state, buckets, lastRun) { for (const [date, metrics] of buckets) { state.days[date] = Object.fromEntries( @@ -51,7 +200,7 @@ function validateClarityState(state, label) { throw new Error(`${label} Clarity state has invalid metrics for ${date}/${slug}.`); } for (const [metric, value] of Object.entries(metrics)) { - if (!['views', 'uniques'].includes(metric) || + if (!CLARITY_METRICS.includes(metric) || !Number.isFinite(value) || value < 0 || !Number.isInteger(value)) { throw new Error(`${label} Clarity state has invalid ${metric} for ${date}/${slug}.`); } diff --git a/tools/catalog-build/test-site-output.js b/tools/catalog-build/test-site-output.js index c75d88f0..0cc03553 100644 --- a/tools/catalog-build/test-site-output.js +++ b/tools/catalog-build/test-site-output.js @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { renderFeed, renderMarkdown, renderResourcePage, renderSitemap } from './site-pages.js'; +import { renderDownloadPage, renderFeed, renderMarkdown, renderResourcePage, renderSitemap } from './site-pages.js'; // Guards the static resource pages (escaping, status handling, sitemap/feed) and the // landing-page behaviors that must not regress (archived hidden, no hard-coded counts). @@ -65,13 +65,23 @@ for (const id of ['strategy', 'agent', 'analytics', 'script']) { assert(/const HIGHLIGHT = \{\s*tag: '[a-z0-9-]+'/.test(indexSource), 'index.html must define the HIGHLIGHT tag.'); assert(!/id="spotlight"/.test(indexSource), 'The hand-written spotlight must not return.'); -// 7. Download buttons: only resources with a derived file get one, and clicks are tracked. +// 7. Download buttons: only resources with a derived file get one, and it opens a counted page. const downloadable = { ...base, slug: 'dl', title: 'Download demo', download: 'scripts/demo/My Script.ps1', downloadName: 'My Script.ps1', downloadSize: 2048 }; const downloadPage = renderResourcePage(downloadable, context([downloadable])); -assert(downloadPage.includes('href="../../scripts/demo/My%20Script.ps1"'), 'The Download link must point at the encoded file path.'); -assert(downloadPage.includes('download="My Script.ps1"') && downloadPage.includes('data-event="resource_download"'), 'The Download link must name the file and fire the Clarity event.'); -assert(downloadPage.includes('2 KB'), 'The Download link must show the file size.'); -assert(!page.includes('resource_download'), 'A resource without a download file must not render the button.'); +assert(downloadPage.includes(''), 'The Download button must open the counted download page.'); +assert(downloadPage.includes('2 KB'), 'The Download button must show the file size.'); +assert(!downloadPage.includes('My%20Script.ps1'), 'The resource page must not link the file directly, or the download would not be counted.'); +assert(!page.includes('href="download/"'), 'A resource without a download file must not render the button.'); + +const counted = renderDownloadPage(downloadable); +assert(counted.includes('data-download-slug="dl"') && !counted.includes('data-slug='), 'The download page must identify itself as a download, not a resource view.'); +assert(counted.includes('href="../../../scripts/demo/My%20Script.ps1" download="My Script.ps1" data-autodownload'), 'The download page must save the encoded file under its name.'); +assert(counted.includes('name="robots" content="noindex"') && counted.includes('rel="canonical" href="https://microsoft.github.io/FastTrack/r/dl/"'), 'The download page must be noindex and point at the resource page.'); +assert(counted.includes('href="../">Back to Download demo') && counted.includes('src="../../../assets/resource-page.js" data-clarity='), 'The download page must link back and load the shared script from three levels down.'); +assert(!/https:\/\/microsoft\.github\.io\/FastTrack\/r\/dl\/download/.test(renderSitemap([downloadable]) + renderFeed([downloadable])), 'Download pages must stay out of the sitemap and feed.'); + +const resourcePageScript = readFileSync(join(toolDirectory, '..', '..', 'assets', 'resource-page.js'), 'utf8'); +assert(resourcePageScript.includes("'resource_download'") && resourcePageScript.includes('data-autodownload'), 'The shared script must record the download and start it.'); const catalogResources = JSON.parse(readFileSync(join(toolDirectory, '..', '..', 'catalog.json'), 'utf8')).resources; const bySlug = slug => catalogResources.find(resource => resource.slug === slug); @@ -79,6 +89,7 @@ assert(bySlug('agent-365-enablement-checklist')?.downloadName === 'agent-365-ena assert(bySlug('microsoft-agent-365-technical-advisory')?.downloadName === 'Agent-365-Technical-Advisory.pdf', 'The advisory must download its PDF.'); assert(catalogResources.filter(resource => resource.status === 'archived').every(resource => !resource.download), 'Archived resources must not offer a download.'); assert(catalogResources.filter(resource => ['bundle', 'declarative', 'md'].includes(resource.format)).every(resource => !resource.download), 'Bundles, declarative agents and Markdown resources must not offer a download.'); -assert(indexSource.includes('btnDetailDownload') && indexSource.includes("'resource_download'"), 'index.html must render the Download button and track it in Clarity.'); +assert(/btnDetailDownload/.test(indexSource) && indexSource.includes('/download/`'), 'index.html must send the Download button to the counted download page.'); +assert(!/id="btnDetailDownload"[^>]*\sdownload[\s>]/.test(indexSource), 'The landing Download button must not link the file directly.'); console.log('Static resource pages and landing-page guards are valid.'); diff --git a/tools/catalog-build/test-stats-state.js b/tools/catalog-build/test-stats-state.js index 3e6623f1..0f6cb8bf 100644 --- a/tools/catalog-build/test-stats-state.js +++ b/tools/catalog-build/test-stats-state.js @@ -1,8 +1,12 @@ import { clarityStateContains, + collectClarityTotals, + downloadSlugFromClarityUrl, + parseClarityTraffic, reconcileClarityStates, replaceClarityDays, slugFromClarityUrl, + subtractClarityTotals, unchangedStatsPayload } from './stats-state.js'; @@ -23,6 +27,74 @@ assert(slugFromClarityUrl('https://microsoft.github.io/FastTrack/r/unknown/', kn assert(slugFromClarityUrl('https://microsoft.github.io/FastTrack/', known) === undefined, 'The landing page is not a resource view.'); +// Loads of the counted download page (/r/slug/download/) are downloads, never views. +const site = 'https://microsoft.github.io/FastTrack'; +assert(slugFromClarityUrl(`${site}/r/ai-council/download/`, known) === undefined, + 'A download page load must not count as a resource view.'); +assert(downloadSlugFromClarityUrl(`${site}/r/ai-council/download/`, known) === 'ai-council' && + downloadSlugFromClarityUrl(`${site}/r/ai-council/download?utm=x`, known) === 'ai-council', + 'The download page must resolve to its slug with or without the trailing slash or a query.'); +assert(downloadSlugFromClarityUrl(`${site}/r/ai-council/`, known) === undefined && + downloadSlugFromClarityUrl(`${site}/r/unknown/download/`, known) === undefined && + downloadSlugFromClarityUrl(`${site}/#ai-council`, known) === undefined, + 'Only the download page of a known resource is a download.'); + +const row = (path, sessions, users, date) => ({ + URL: `${site}${path}`, totalSessionCount: String(sessions), distinctUserCount: String(users), ...(date ? { Date: date } : {}) +}); +const traffic = rows => [{ metricName: 'Traffic', information: rows }]; + +const rolling = parseClarityTraffic(traffic([ + row('/r/ai-council/download/', 3, 2), + row('/r/ai-council/', 10, 6), + row('/#ai-council', 5, 4), + row('/r/agents-cost-calculator/download/', 1, 1), + row('/r/unknown/download/', 9, 9), + row('/', 99, 50) +]), known); +assert(JSON.stringify(rolling.totals.get('ai-council')) === '{"views":15,"uniques":10,"downloads":3}', + 'Views, uniques and downloads must be summed per resource in a fixed key order.'); +assert(JSON.stringify(rolling.totals.get('agents-cost-calculator')) === '{"downloads":1}', + 'A resource with only downloads must not invent views.'); +assert(rolling.totals.size === 2 && rolling.dated.size === 0, 'Unknown and non-resource URLs must be ignored.'); + +const dailyExport = parseClarityTraffic(traffic([ + row('/r/ai-council/download/', 2, 2, '2026-10-28'), + row('/r/ai-council/', 4, 3, '2026-10-28'), + row('/r/ai-council/download/', 1, 1, '2026-10-29') +]), known); +assert(JSON.stringify(dailyExport.dated.get('2026-10-28').get('ai-council')) === '{"views":4,"uniques":3,"downloads":2}' && + JSON.stringify(dailyExport.dated.get('2026-10-29').get('ai-council')) === '{"downloads":1}', + 'Dated exports must produce one bucket per day.'); + +const threeDays = parseClarityTraffic(traffic([row('/r/ai-council/', 9, 5), row('/r/ai-council/download/', 4, 3)]), known).totals; +const twoDays = parseClarityTraffic(traffic([row('/r/ai-council/', 6, 4), row('/r/ai-council/download/', 3, 2)]), known).totals; +assert(JSON.stringify(subtractClarityTotals(threeDays, twoDays).get('ai-council')) === '{"views":3,"uniques":1,"downloads":1}', + 'Cumulative windows must be differenced for downloads as well as views.'); +const warn = console.warn; +console.warn = () => {}; +const shrinking = subtractClarityTotals( + parseClarityTraffic(traffic([row('/r/ai-council/', 9, 5), row('/r/ai-council/download/', 1, 1)]), known).totals, + parseClarityTraffic(traffic([row('/r/ai-council/', 6, 4), row('/r/ai-council/download/', 3, 2)]), known).totals +); +console.warn = warn; +assert(JSON.stringify(shrinking.get('ai-council')) === '{"views":3,"uniques":1}', + 'A negative download difference must be skipped without dropping the views.'); + +const totalsState = { + lastRun: '2026-10-29', + days: { + '2026-10-28': { 'ai-council': { views: 4, uniques: 3, downloads: 2 }, ghost: { views: 1 } }, + '2026-10-29': { 'ai-council': { downloads: 1 }, 'agents-cost-calculator': { views: 2 } } + } +}; +const collected = collectClarityTotals(totalsState, known); +assert(JSON.stringify(collected.get('ai-council')) === '{"views":4,"viewsUniques":3,"downloads":3}' && + JSON.stringify(collected.get('agents-cost-calculator')) === '{"views":2}' && !collected.has('ghost'), + 'Persisted days must add up to per-resource views and downloads for known resources only.'); +assert(clarityStateContains(totalsState, { lastRun: '2026-10-29', days: { '2026-10-29': { 'ai-council': { downloads: 1 } } } }), + 'A downloads metric must be a valid persisted metric.'); + const state = { lastRun: '2026-09-10', days: { @@ -45,8 +117,8 @@ assert(state.days['2026-09-12']['new-resource'].views === 3, assert(state.lastRun === '2026-09-12', 'The state must record the latest successful run.'); const payload = { - sources: { views: 'clarity', upvotes: 'github-discussions' }, - resources: { resource: { views: 5 } } + sources: { views: 'clarity', downloads: 'clarity', upvotes: 'github-discussions' }, + resources: { resource: { views: 5, downloads: 2 } } }; assert(unchangedStatsPayload({ generatedAt: 'old', ...payload }, payload), 'Timestamp-only differences must not create a new stats commit.'); diff --git a/traffic-data/README.md b/traffic-data/README.md index b55b7e45..9a5395b7 100644 --- a/traffic-data/README.md +++ b/traffic-data/README.md @@ -6,7 +6,7 @@ branch so Clarity's three-day export window is preserved without daily pull requ **Files:** - `YYYY-MM-DD.json` — Full daily snapshot (views, clones, referrers, popular paths). Collection of these is paused; see below. -- `clarity-views.json` — Accumulated Clarity page views per resource, the source of the view counts on the site. +- `clarity-views.json` — Accumulated Clarity page views and downloads per resource, the source of the view and download counts in `resource-stats.json`. A download is a load of the resource's download page (`r//download/`), which the Download button opens; it is never counted as a view. Data is collected daily at 06:00 UTC via GitHub Actions. Each Friday at 08:00 `America/Chicago` (including daylight saving time), the workflow updates one