diff --git a/.gitattributes b/.gitattributes index 02a2eb7..3783121 100644 --- a/.gitattributes +++ b/.gitattributes @@ -31,6 +31,7 @@ *.jpg filter=lfs diff=lfs merge=lfs -text *.pdf filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text +/og-image.png -filter -diff -merge -text *.psd filter=lfs diff=lfs merge=lfs -text *.webp filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/github_workflows_build.yml b/.github/workflows/github_workflows_build.yml index d47f8ef..1355739 100644 --- a/.github/workflows/github_workflows_build.yml +++ b/.github/workflows/github_workflows_build.yml @@ -27,6 +27,8 @@ jobs: steps: - name: Checkout this repo uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Cache winget-pkgs repo uses: actions/cache@v4 @@ -82,29 +84,8 @@ jobs: echo "Successfully created packages.json (${size} bytes)" - - name: Copy site assets - run: | - # Create a deployment directory - mkdir -p deploy - - # Copy files (overwrite if exists) - cp index.html deploy/ - cp search.js deploy/ - cp -R vendor deploy/ - # Stamp dateModified with today's date so Dataset structured data stays current - sed -i "s/\"dateModified\": \"[^\"]*\"/\"dateModified\": \"$(date -u +%Y-%m-%d)\"/" deploy/index.html - cp agent-access.html deploy/ - cp llms.txt deploy/ - cp packages.json deploy/ - - # Create .nojekyll to prevent Jekyll processing - touch deploy/.nojekyll - - # Create a simple 404 page - echo '' > deploy/404.html - - # Create a CNAME file if you're using a custom domain - # echo "your-custom-domain.com" > deploy/CNAME + - name: Build site assets and metadata + run: python build_site.py --output-dir deploy - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 @@ -142,4 +123,4 @@ jobs: echo "- **File size**: $(stat -c%s packages.json | numfmt --to=iec-i --suffix=B)" >> $GITHUB_STEP_SUMMARY echo "- **Run number**: ${{ github.run_number }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "Visit the site at: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/" >> $GITHUB_STEP_SUMMARY + python -c 'import json; print("Visit the site at: " + json.load(open("site_config.json"))["public_base_url"])' >> "$GITHUB_STEP_SUMMARY" diff --git a/NEXT_PROMPT.md b/NEXT_PROMPT.md index 7b1af70..934f404 100644 --- a/NEXT_PROMPT.md +++ b/NEXT_PROMPT.md @@ -1,45 +1,42 @@ Continue work in /Users/solrevdev/Projects/winget-search. -Read NEXT_STEPS.md first. It is the current agreed roadmap and work/resource log. -IMPROVEMENTS.md contains older notes that may already be resolved. - -PR #7 is merged, deployed, and verified live. Its feature branch is deleted. -Master is clean and synced. The task-owned preview servers and Playwright sessions -from the previous round are stopped. Check the resource log and current state -before assuming anything is still running. - -Keep hosting free on GitHub Pages, extraction in GitHub Actions, and search in the -browser. Preserve the full packages.json endpoint and existing query/filter URLs. - -Propose the next bounded batch before implementing and wait for my agreement. -The next chunk is to finish PR #5 and the useful remaining work in issue #1 as one -SEO change. PR #5 now has merge conflicts with master. Inspect its diff and checks, -resolve those conflicts, refresh its branch safely, and -preserve feat/seo-meta-sitemap-dataset until the PR is merged or otherwise resolved. -Update the existing PR rather than duplicating it. - -Use the roadmap's findings to check canonical/social metadata, preview assets, -Dataset fields and package count, accurate sitemap dates, semantic markup, and -accessibility. Check current primary-source guidance where needed. Coordinate the -public-base-URL rule with issue #4's redirect work; keep the rest of issue #4 in a -separate maintenance batch. Record Search Console work that needs an account check. -Do not close issues until their acceptance criteria and required live checks pass. - -Use subagents for independent implementation or review when helpful. Give each -clear file ownership, tell them others are working in the repo, and require them to -report any resources they start to the parent task. - -After scope approval, implement and test the batch. Preserve the 38 Node and nine -Python regressions, add meaningful checks for changed behavior, validate generated -assets, and test affected desktop/mobile flows. Update NEXT_STEPS.md with decisions, -results, remaining work, and the PR link. Leave the PR ready for review. Wait for -approval before merging or deploying; after approval, verify both deployment stages -and the live site before deleting completed branches and syncing master. - -Track every server, browser session, background process, and temporary preview as -soon as it starts in NEXT_STEPS.md's resource log. Record ownership, purpose, PID or -tool session ID, browser session name, port/URL, paths, and status. At each PR -handoff, list what remains active and offer to tear it down in one short question. -If cleanup is already authorized, complete it and verify the processes, ports, and -sessions are gone. Preserve unrelated resources. Update the log and this continuation -prompt for the next round. +Read NEXT_STEPS.md first, then this file. SEO.md records the public URL contract, +SEO decisions, primary sources and release checks. IMPROVEMENTS.md is historical. + +PR #7 is merged, deployed and verified. PR #5's conflict repair is c5de9d1. +The user approved the SEO batch for PR #5 plus useful issue #1 work. Implementation +and local checks are complete on feat/seo-meta-sitemap-dataset in the existing +PR #5. Check its current head, CI and mergeability before acting. + +Wait for explicit merge/deployment approval. No deployment or issue closure was +authorized for this handoff. After approval, merge and verify both Build and Deploy +and Pages publication, then live metadata, catalog counts/dates, sitemap, preview +image, redirect and desktop/mobile search. Preserve the feature branch until that +verification passes; then remove it and sync master. + +All 38 Node tests pass. All nine Python extraction regressions plus ten build tests +pass (19 Python total). Generated HTML/JSON-LD/XML/images and workflow checks pass. +Search and SEO browser suites pass at 1280px and 375px. Axe light/dark page and Copy +state checks pass after fixing contrast and keyboard access. Review NEXT_STEPS.md +for evidence and resource status. All PR #5 servers/browser sessions are stopped; +temporary evidence is inert. Preserve unrelated resources. + +Keep GitHub Pages hosting free, extraction in Actions, and search in the browser. +Preserve packages.json and query/filter URLs. site_config.json's public_base_url +feeds build_site.py's metadata, sitemap, guide examples and 404 destination. + +PR #5 includes only the shared URL/redirect portion of issue #4. Keep README, +cache/default-branch logic and force_pages_update.sh maintenance separate. Start +that later branch from synced master after PR #5 merges, reuse its URL rule and +check mergeability again. Propose the next bounded batch and await agreement. + +Search Console still needs an account check for sitemap submission, URL indexing, +and the old Dataset creator/license validation status. Run Rich Results Test on +the deployed homepage. Do not close issues before their remaining criteria and +required live checks pass. + +Use subagents when useful with clear file ownership; they must report resources +to the parent. Record any server, browser, background job or preview immediately +in NEXT_STEPS.md with owner, purpose, PID/tool session, browser name, port/URL, +paths and status. At handoff list active resources and offer cleanup; perform any +already-authorized cleanup and verify it. Update both handoff files each round. diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index efcbce7..df0eec1 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -111,36 +111,60 @@ and search stays in the browser. The catalog endpoint and existing `?q=` links r SEO and deployment maintenance remain separate changes. The findings below define their scope; this search batch does not implement them or close their issues. -### PR #5 and issue #1: one SEO change - -[PR #5](https://github.com/solrevdev/winget-search/pull/5) remains open at -`949eec26e58f8f392d505a4cabba752264d3da94`. After PR #7 merged, GitHub reports -merge conflicts with `master`; resolve them as part of the next SEO batch. Preserve -`feat/seo-meta-sitemap-dataset` and complete the useful issue #1 work there. - -- Canonical and social metadata are absent from the live homepage. PR #5 supplies - these, including a PNG preview and its SVG source. Its description is stale: - it still lists the image as future work despite including it in the diff. -- The count stamp reads the correct `metadata.total` field. Validate it against - the actual array length. Change `variableMeasured` from `packageId` to `id`. - Review the count representation: Schema.org defines `numberOfItems` for - [ItemList](https://schema.org/numberOfItems), not Dataset. -- Use catalog freshness for Dataset dates and meaningful page-change dates for - sitemap entries. Do not stamp an unchanged agent-access page with every build's - date. [Google's sitemap guidance](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) - calls for accurate last-modified dates and ignores priority/change frequency. -- The homepage already has a title, description, h1, and footer. Add useful - header/main landmarks, result headings, and appropriate treatment of decorative - SVGs. This search batch adds an explicit search label. Keep the PNG's social alt - descriptions. Do not add - the issue's keyword meta tag: [Google ignores it](https://developers.google.com/search/docs/crawling-indexing/special-tags). -- Share a single public-base-URL rule with issue #4's generated redirect. Validate - project paths and custom-domain paths together with canonical and sitemap URLs. -- Validate generated HTML, JSON-LD, sitemap, and preview assets before review. - After an approved merge, verify both deployment stages and the live output. - Search Console submission and the old validation status still need a separate - account check. Keep issue #1 open until its remaining criteria are resolved and - the deployed result is verified. +### PR #5 and issue #1: SEO ready for review + +[PR #5](https://github.com/solrevdev/winget-search/pull/5) remains on +`feat/seo-meta-sitemap-dataset`. Conflict repair `c5de9d1` merged master `35bed66` +and passed CI. The user approved the SEO scope on 2026-09-13. The implementation +is complete and awaiting review and merge approval. Nothing has deployed from +this batch; issues #1 and #4 remain open. + +- `build_site.py` replaces shell stamps with a tested static build. One explicit + HTTPS directory URL in `site_config.json` drives canonical/social metadata, + Dataset download, sitemap, guide examples, and the generated 404 destination. + Custom roots/subpaths and GitHub Pages user/project paths are covered. This + completes the redirect portion of issue #4, subject to release verification. +- The build rejects empty/invalid catalogs and count mismatches. The preview used + 14,836 records and `2026-09-13T18:17:57.426295Z`, matching the live download. + Dataset description and visible summary show the actual count; `dateModified` + uses extraction time. `variableMeasured` uses `id`, not `packageId`. +- Removed Dataset `numberOfItems`, unsupported historical coverage/publication + dates, and the repository-as-Dataset `sameAs` claim. John Smith matches the + public owner profile; creator points to that profile. The MIT catalog license + stays and does not describe individual software licenses. +- Sitemap dates use significant source/catalog changes, with no daily date stamp + for an unchanged agent page. Unknown source dates are omitted. No `priority`, + `changefreq`, or keywords meta tag is added. See [SEO.md](SEO.md) for primary + guidance, public URL rules, and Search Console follow-up. +- Added header/main landmarks, skip links, package headings, hidden decorative + SVGs, stronger text/button contrast, underlined footer links, wrapped install + commands, and keyboard scrolling for guide code examples. The 1200 × 630 PNG + was regenerated from its SVG after fixing overflowing caption text. +- All 38 Node regressions and all nine extraction tests pass, plus ten new Python + build tests (19 Python total). Generated homepage, agent page, and 404 pass + html-validate 11.15.0's HTML standard preset; JSON-LD/XML/PNG/SVG and workflow YAML + checks pass. Search engine, extractor, vendor assets and catalog bytes remain + unchanged. No required runtime service or app dependency was added. +- Saved search and SEO browser suites pass at 1280px and 375px with no uncaught + exceptions. Checks cover ranking, URLs/history, filters, copy, Details, paging, + empty/error recovery, landmarks, metadata, static count/freshness, keyboard + navigation and overflow. Screenshots were inspected. Axe 4.13.0 reports zero + violations in eight light/dark desktop/mobile page checks and four Copy + hover/success checks after contrast and keyboard fixes. This is automated plus + keyboard review, not a claim of full assistive-technology certification. + +Keep this existing PR and branch. Start the later maintenance branch from synced +master after PR #5 merges, reuse the URL rule, and recheck mergeability at every +handoff. No separate competing SEO PR is needed. Future master edits can still +introduce conflicts. + +After approval: merge, verify both Build and Deploy and Pages publication, then +verify live metadata/counts/dates, sitemap, preview image, redirect and search. +Only then remove the completed feature branch and sync master. Search Console +still requires an account check: confirm sitemap submission and indexing, inspect +both page URLs, and check the old creator/license validation status. Run Rich +Results Test against the deployed homepage. Keep issue #1 open until the required +live and account checks pass. ### Issue #4: deployment and documentation follow-up @@ -153,8 +177,9 @@ merge conflicts with `master`; resolve them as part of the next SEO batch. Prese - Trial a versioned UTC daily key with prefix restore. Always run upstream checkout so both exact and prefix restores refresh. Remove the separate update step that assumes `origin/master`. -- Generate the 404 destination using the public URL rule established with PR #5. - Test project paths, user-site roots, and custom domains. +- PR #5 implements the shared URL rule and generated 404 destination, with + project/user-site/custom-domain tests. Reuse that builder after PR #5 merges; + do not reimplement or revert its redirect logic in the maintenance PR. - Make `force_pages_update.sh` restore the starting branch on success and failure, reject dirty worktrees, use fast-forward-only updates, and stage only intended files. Test success and fetch/push failures with temporary local Git remotes. @@ -178,6 +203,10 @@ running resources. | Resource | Owner and location | Status | | --- | --- | --- | +| Playwright `winget-seo-pr5` | PR #5 parent; PID 66266; open exec session 51766; `http://127.0.0.1:8765/winget-search/`; screenshots/logs in `.playwright-cli/` | Passed and closed 2026-09-13; PID and session checked; search suite sessions 32055 and 89714 completed | +| SEO preview server, PID 66230 | PR #5 parent; exec session 12065; port 8765; `http://127.0.0.1:8765/winget-search/`; `/private/tmp/winget-seo-pr5-20260913/preview` | Stopped 2026-09-13; PID and port checked | +| SEO review files | PR #5 parent; `/private/tmp/winget-seo-pr5-20260913`; catalog, generated previews, isolated validation tools/npm cache and evidence; no port or browser yet | Inert files; live catalog has 14,836 records | +| Validation tool install | PR #5 parent; exec session 48988; `/private/tmp/winget-seo-pr5-20260913/tools` and `npm-cache`; no port/browser | Completed; axe-core 4.13.0 and html-validate 11.15.0 installed; no process remains | | Preview server, PID 62533 | PR #7; port 8765; `http://127.0.0.1:8765/winget-search/`; `/private/tmp/winget-preview`; exec session 32615 | Stopped 2026-09-13; port checked | | Staged preview server, PID 63164 | PR #7; port 8766; `http://127.0.0.1:8766/winget-search/`; `/private/tmp/winget-review-preview`; exec session 57935 | Stopped 2026-09-13; port checked | | Playwright `winget-search` | PR #7 desktop/mobile-width checks | Closed 2026-09-13 | @@ -185,10 +214,18 @@ running resources. | Playwright `winget-release-check`, PID 63851 | PR #7 public deployment verification; `https://solrevdev.com/winget-search/`; no local server | Passed and closed 2026-09-13; PID checked | Preview directories and `.playwright-cli/` screenshots/logs are inert evidence. +PR #5 also retains JSON test results and isolated validation tools under +`/private/tmp/winget-seo-pr5-20260913`. All PR #5 task resources are stopped; +no active browser, preview server or background test remains. No task-owned servers or Playwright sessions remain active. Verified with `playwright-cli list`, process checks, and listener checks on ports 8765 and 8766. Unrelated services were left running. +PR #5 conflict repair (2026-09-13): no servers, browser sessions, background +processes, or temporary previews started. The read-only SEO scope subagent owns +review findings only and reports any resources to the parent. Its review is complete. Build and markup subagents started no servers or +browsers; their Python test fixture directories were cleaned automatically. + ## Following batches 1. **Install-list builder.** Select multiple packages, keep selections in local diff --git a/SEO.md b/SEO.md new file mode 100644 index 0000000..35658c8 --- /dev/null +++ b/SEO.md @@ -0,0 +1,88 @@ +# SEO build and review + +The site uses static metadata to describe the catalog and its preferred public +URLs. These changes support discovery and sharing; they do not promise a ranking +increase. Search still runs in the browser, extraction runs in GitHub Actions, +and GitHub Pages serves the generated files for free. + +## Public URL contract + +`site_config.json` holds `public_base_url`, currently +`https://solrevdev.com/winget-search/`. It is the complete HTTPS directory URL, +including any project path. The build normalizes its trailing slash. For another +deployment, change that value or pass `--public-base-url` to `build_site.py`. + +The same value controls homepage and agent-page canonical links, Open Graph URLs, +social images, the Dataset download URL, sitemap entries, and the 404 destination. +It does not rewrite search query/filter parameters or change `packages.json`. +Changing this value does not configure DNS or GitHub Pages itself. + +Supported forms include a GitHub Pages project path, a user-site root, and a +custom-domain root or subpath. The remaining issue #4 maintenance work must reuse +this rule rather than add another redirect-path setting. + +## Catalog metadata and dates + +The build requires `metadata.total` to equal the number of package records. It +uses that count in the Dataset description and the visible catalog summary. +`variableMeasured` names actual JSON fields: `id`, `name`, `version`, `publisher`. +Dataset freshness comes from `metadata.extracted_at`, which the extractor writes +in UTC. The full catalog is copied unchanged. + +The existing creator, John Smith, matches the public `solrevdev` GitHub profile. +The source repository and upstream manifest repository both use MIT. That catalog +license does not replace the licenses of the individual software packages. +Unverified publication/coverage dates and the repository-as-Dataset `sameAs` claim +are omitted. + +The homepage sitemap date follows its latest meaningful source or catalog change. +The agent page follows its source changes, not the daily extraction date. If the +build cannot establish a source date, it omits that date rather than invent one. +The workflow fetches source history for these dates. + +## Deliberate omissions + +- Google ignores the keywords meta tag, so issue #1's suggestion is not applied. +- `numberOfItems` belongs to `ItemList`, not `Dataset`; a plain description gives + the count without misusing the schema. +- Google ignores sitemap `priority` and `changefreq`; neither is generated. +- A project-path `robots.txt` cannot control the host root. Any root-level sitemap + declaration belongs to the owner of `https://solrevdev.com/robots.txt`. +- Query/filter combinations keep the homepage canonical. This batch does not + create indexable package detail pages. + +## Review and release + +Run the Node and Python suites described in `NEXT_STEPS.md`. Build a preview with +a real catalog outside the checkout: + +```sh +python build_site.py --packages /path/to/packages.json --output-dir /path/to/preview/winget-search +``` + +Check generated HTML, JSON-LD, sitemap XML, the social PNG and its SVG source. +Run `tests/browser-search.js` and `tests/browser-seo.js` against that preview at +desktop/mobile widths, including keyboard use. Keep test tools and downloaded +catalogs outside the committed site. Record evidence and resources in +`NEXT_STEPS.md`. + +After merge approval, verify both Build and Deploy and Pages publication, then +check the live canonical/social tags, catalog count and dates, sitemap, image, +redirect, and search flows. Keep issues open until their remaining criteria pass. + +Search Console needs an account check after deployment: submit or confirm +`https://solrevdev.com/winget-search/sitemap.xml`, inspect both public pages, and +check the old Dataset creator/license validation status. The old PR's report that +validation had started is historical evidence, not a current result. Run Google's +Rich Results Test on the deployed homepage too. + +## Primary guidance checked + +- [Google Dataset structured data](https://developers.google.com/search/docs/appearance/structured-data/dataset) +- [Google canonical URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls) +- [Google sitemap dates](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap) +- [Google supported meta tags](https://developers.google.com/search/docs/crawling-indexing/special-tags) +- [Schema.org numberOfItems](https://schema.org/numberOfItems) +- [Open Graph image and alt properties](https://ogp.me/) +- [Public creator profile](https://github.com/solrevdev) +- [Upstream manifest license](https://github.com/microsoft/winget-pkgs/blob/master/LICENSE) diff --git a/agent-access.html b/agent-access.html index 07afd91..dc58fb6 100644 --- a/agent-access.html +++ b/agent-access.html @@ -5,6 +5,7 @@ API & Agent Access · Winget Package Search + - ← Back to package search -
+ +
+ ← Back to package search

API & agent access

Query the same package catalog used by the browser interface.

- +
+

This is a static GitHub Pages site, not a server-side search API. Query parameters such as ?q=nssm are handled by JavaScript in a browser. Scripts and agents should download @@ -110,7 +126,7 @@

Catalog endpoint

macOS and Linux

Requires curl and jq. Download once, then change query for additional searches.

-
curl -fsSL https://solrevdev.com/winget-search/packages.json -o packages.json
+    
curl -fsSL https://solrevdev.com/winget-search/packages.json -o packages.json
 
 query='nssm'
 jq --arg q "$query" '
@@ -128,7 +144,7 @@ 

macOS and Linux

Windows PowerShell and PowerShell 7+

This example is compatible with Windows PowerShell 5.1 and cross-platform PowerShell 7 or later.

-
$catalog = Invoke-RestMethod -Uri 'https://solrevdev.com/winget-search/packages.json'
+    
$catalog = Invoke-RestMethod -Uri 'https://solrevdev.com/winget-search/packages.json'
 $query = 'nssm'
 
 $catalog.packages |
@@ -142,7 +158,7 @@ 

Windows PowerShell and PowerShell 7+

Create install commands

After filtering results in either shell, use the exact package ID:

-
winget install -e --id NSSM.NSSM
+
winget install -e --id NSSM.NSSM

Machine-oriented instructions are also available in llms.txt.

diff --git a/build_site.py b/build_site.py new file mode 100644 index 0000000..3468204 --- /dev/null +++ b/build_site.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Build the static Pages site from its source and extracted catalog.""" + +import argparse +from datetime import datetime, timezone +from html import escape, unescape +import json +from pathlib import Path +import re +import shutil +import subprocess +from urllib.parse import unquote, urlsplit, urlunsplit +import xml.etree.ElementTree as ET + + +SITEMAP_NS = "http://www.sitemaps.org/schemas/sitemap/0.9" +ASSETS = ("search.js", "agent-access.html", "llms.txt", "og-image.png", "og-image.svg") + + +def normalize_public_base_url(value): + """Use an explicit HTTPS origin and optional path, always ending in a slash.""" + if not isinstance(value, str) or not value or any(c.isspace() for c in value): + raise ValueError("public_base_url must be an HTTPS URL without whitespace") + try: + parts = urlsplit(value) + port = parts.port + except ValueError as exc: + raise ValueError("public_base_url is malformed") from exc + if (parts.scheme != "https" or not parts.hostname or parts.username is not None + or parts.password is not None or parts.query or parts.fragment + or "?" in value or "#" in value or "\\" in value): + raise ValueError("public_base_url must use HTTPS without credentials, query, or fragment") + if port is not None and port != 443: + raise ValueError("public_base_url must use the default HTTPS port") + host = parts.hostname.lower() + if not re.fullmatch(r"[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?", host): + raise ValueError("public_base_url must have a valid public hostname") + if any(not label or len(label) > 63 or label.startswith("-") or label.endswith("-") + for label in host.split(".")): + raise ValueError("public_base_url must have a valid public hostname") + path = parts.path + if re.search(r"%(?![0-9a-fA-F]{2})", path): + raise ValueError("public_base_url contains an invalid escape") + decoded = unquote(path) + if (any(c.isspace() or ord(c) < 32 for c in decoded) + or any(c in decoded for c in '\\?#<>"\'') + or "//" in decoded or any(p in (".", "..") for p in decoded.split("/"))): + raise ValueError("public_base_url contains an unsafe path") + return urlunsplit(("https", host, path.rstrip("/") + "/", "", "")) + + +def parse_timestamp(value): + if not isinstance(value, str) or "T" not in value: + raise ValueError("metadata.extracted_at must be an ISO timestamp") + try: + result = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("metadata.extracted_at must be an ISO timestamp") from exc + # The extractor uses datetime.utcnow().isoformat(), which has no UTC suffix. + if result.tzinfo is None: + result = result.replace(tzinfo=timezone.utc) + return result.astimezone(timezone.utc) + + +def read_catalog(path): + catalog = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(catalog, dict) or not isinstance(catalog.get("metadata"), dict): + raise ValueError("catalog must contain metadata and packages") + packages = catalog.get("packages") + total = catalog["metadata"].get("total") + if not isinstance(packages, list) or not packages: + raise ValueError("catalog packages must be a nonempty array") + if type(total) is not int or total != len(packages): + raise ValueError("metadata.total must match the packages array length") + if any(not isinstance(package, dict) for package in packages): + raise ValueError("catalog packages must contain objects") + return total, parse_timestamp(catalog["metadata"].get("extracted_at")) + + +def git_modified(source_dir, paths): + """Ignore checkout mtimes: only tracked content history supplies page dates.""" + try: + result = subprocess.run( + ["git", "-C", str(source_dir), "log", "-1", "--format=%cI", "--", *paths], + check=True, capture_output=True, text=True, + ) + return parse_timestamp(result.stdout.strip()) if result.stdout.strip() else None + except (OSError, subprocess.CalledProcessError, ValueError): + return None + + +def replace_attribute(document, tag, key, match_value, attribute, value): + pattern = rf'<{tag}\b[^>]*\b{key}=["\']{re.escape(match_value)}["\'][^>]*>' + tags = list(re.finditer(pattern, document, re.IGNORECASE)) + if len(tags) != 1: + raise ValueError(f"expected one {tag} with {key}={match_value}") + old = tags[0].group() + attr_pattern = rf'\b{attribute}=["\'][^"\']*["\']' + replacement, count = re.subn(attr_pattern, lambda _: f'{attribute}="{escape(value, quote=True)}"', old) + if count != 1: + raise ValueError(f"expected one {attribute} in {old}") + return document[:tags[0].start()] + replacement + document[tags[0].end():] + + +def build_index(document, public_base_url, total, extracted_at): + document = replace_attribute(document, "link", "rel", "canonical", "href", public_base_url) + for key, name, value in ( + ("property", "og:url", public_base_url), + ("property", "og:image", public_base_url + "og-image.png"), + ("property", "og:image:secure_url", public_base_url + "og-image.png"), + ("name", "twitter:image", public_base_url + "og-image.png"), + ): + document = replace_attribute(document, "meta", key, name, "content", value) + pattern = r'()(.*?)()' + matches = list(re.finditer(pattern, document, re.DOTALL)) + if len(matches) != 1: + raise ValueError("expected one Dataset JSON-LD block") + dataset = json.loads(matches[0].group(2)) + if dataset.get("@type") != "Dataset": + raise ValueError("expected Dataset structured data") + for key in ("numberOfItems", "temporalCoverage", "datePublished", "sameAs"): + dataset.pop(key, None) + dataset.update({ + "description": f"Metadata for {total:,} Windows Package Manager packages extracted from microsoft/winget-pkgs.", + "url": public_base_url, + "dateModified": extracted_at.isoformat().replace("+00:00", "Z"), + "variableMeasured": ["id", "name", "version", "publisher"], + "distribution": {"@type": "DataDownload", "encodingFormat": "application/json", + "contentUrl": public_base_url + "packages.json"}, + }) + encoded = json.dumps(dataset, ensure_ascii=False, indent=2).replace("<", "\\u003c") + document = re.sub(pattern, lambda m: m.group(1) + "\n" + encoded + "\n " + m.group(3), document, flags=re.DOTALL) + summary = (dataset["description"] + " " + f'Catalog updated ' + ".") + document, count = re.subn(r'(]*\bid="catalog-summary"[^>]*>).*?(

)', + lambda m: m.group(1) + summary + m.group(2), document, flags=re.DOTALL) + if count != 1: + raise ValueError("expected one catalog-summary paragraph") + return document + + +def build_sitemap(template, public_base_url, home_modified, agent_modified): + root = ET.fromstring(template) + ns = {"s": SITEMAP_NS} + entries = root.findall("s:url", ns) + locations = {"{{PUBLIC_BASE_URL}}": (public_base_url, home_modified), + "{{AGENT_URL}}": (public_base_url + "agent-access.html", agent_modified)} + if len(entries) != len(locations): + raise ValueError("sitemap template must contain the homepage and agent page") + for entry in entries: + loc = entry.find("s:loc", ns) + if loc is None or loc.text not in locations: + raise ValueError("sitemap template contains an unknown location") + url, modified = locations.pop(loc.text) + loc.text = url + for child in list(entry): + if child is not loc: + entry.remove(child) + if modified: + ET.SubElement(entry, f"{{{SITEMAP_NS}}}lastmod").text = modified.date().isoformat() + ET.register_namespace("", SITEMAP_NS) + ET.indent(root, space=" ") + return ET.tostring(root, encoding="unicode", xml_declaration=True) + "\n" + + +def build_site(source_dir, output_dir, packages_path=None, public_base_url=None): + source_dir = Path(source_dir).resolve() + output_dir = Path(output_dir).resolve() + packages_path = Path(packages_path).resolve() if packages_path else source_dir / "packages.json" + if output_dir == source_dir or output_dir in source_dir.parents: + raise ValueError("output directory must not contain the source directory") + if public_base_url is None: + config = json.loads((source_dir / "site_config.json").read_text(encoding="utf-8")) + public_base_url = config["public_base_url"] + public_base_url = normalize_public_base_url(public_base_url) + total, extracted_at = read_catalog(packages_path) + index_source = (source_dir / "index.html").read_text(encoding="utf-8") + canonical = re.search(r']*\brel="canonical"[^>]*\bhref="([^"]+)"', index_source) + if canonical is None: + raise ValueError("expected a source canonical URL") + source_base_url = normalize_public_base_url(unescape(canonical.group(1))) + index = build_index(index_source, public_base_url, total, extracted_at) + agent = (source_dir / "agent-access.html").read_text(encoding="utf-8").replace( + escape(source_base_url, quote=True), escape(public_base_url, quote=True)) + agent = replace_attribute(agent, "link", "rel", "canonical", "href", public_base_url + "agent-access.html") + guide = (source_dir / "llms.txt").read_text(encoding="utf-8").replace(source_base_url, public_base_url) + shared = ["build_site.py", "site_config.json"] + home_modified = git_modified(source_dir, ["index.html", "search.js", "sitemap.xml", *shared]) + home_modified = max(extracted_at, home_modified) if home_modified else extracted_at + agent_modified = git_modified(source_dir, ["agent-access.html", *shared]) + sitemap = build_sitemap((source_dir / "sitemap.xml").read_text(encoding="utf-8"), + public_base_url, home_modified, agent_modified) + for asset in ASSETS: + if not (source_dir / asset).is_file(): + raise ValueError(f"missing site asset: {asset}") + if not (source_dir / "vendor").is_dir(): + raise ValueError("missing vendor directory") + output_dir.mkdir(parents=True, exist_ok=True) + for asset in ASSETS: + shutil.copy2(source_dir / asset, output_dir / asset) + shutil.copytree(source_dir / "vendor", output_dir / "vendor", dirs_exist_ok=True) + shutil.copy2(packages_path, output_dir / "packages.json") + (output_dir / "index.html").write_text(index, encoding="utf-8") + (output_dir / "agent-access.html").write_text(agent, encoding="utf-8") + (output_dir / "llms.txt").write_text(guide, encoding="utf-8") + (output_dir / "sitemap.xml").write_text(sitemap, encoding="utf-8") + (output_dir / ".nojekyll").touch() + destination = escape(public_base_url, quote=True) + (output_dir / "404.html").write_text( + '\n' + '' + f'' + 'Page not found | Winget Package Search' + f'

Page not found

' + 'Search WinGet packages

\n', encoding="utf-8") + return {"public_base_url": public_base_url, "total": total, + "extracted_at": extracted_at.isoformat().replace("+00:00", "Z")} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-dir", type=Path, default=Path(__file__).resolve().parent) + parser.add_argument("--packages", type=Path) + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--public-base-url") + args = parser.parse_args() + try: + result = build_site(args.source_dir, args.output_dir or args.source_dir / "deploy", + args.packages, args.public_base_url) + except (ValueError, KeyError, OSError) as exc: + parser.exit(1, f"Site build failed: {exc}\n") + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/index.html b/index.html index 3e12052..a94e2d8 100644 --- a/index.html +++ b/index.html @@ -5,8 +5,30 @@ Winget Package Search + + + + + + + + + + + + + + + + + + + + + + ', document, re.S).group(1)) + self.assertEqual(result["total"], 2) + self.assertIn("2 Windows Package Manager packages", dataset["description"]) + self.assertEqual(dataset["dateModified"], "2026-09-12T20:15:30.123456Z") + self.assertEqual(dataset["variableMeasured"], ["id", "name", "version", "publisher"]) + self.assertEqual(dataset["distribution"]["contentUrl"], "https://example.com/tools/packages.json") + self.assertEqual(dataset["creator"]["url"], "https://github.com/solrevdev") + self.assertEqual(dataset["license"], "https://spdx.org/licenses/MIT.html") + for invalid in ("numberOfItems", "temporalCoverage", "datePublished", "sameAs"): + self.assertNotIn(invalid, dataset) + summary = re.search(r']*id="catalog-summary"[^>]*>(.*?)

', document, re.S).group(1) + self.assertIn(dataset["description"], summary) + self.assertIn('datetime="2026-09-12T20:15:30.123456Z"', summary) + self.assertIn("12 September 2026 UTC", summary) + + def test_offset_timestamp_is_normalized_to_utc(self): + self.catalog["metadata"]["extracted_at"] = "2026-09-13T00:30:00+02:00" + self.save_catalog() + self.assertEqual(self.build()["extracted_at"], "2026-09-12T22:30:00Z") + + def test_invalid_catalog_fails_before_creating_output(self): + for catalog in ({}, [], {"metadata": {}, "packages": []}, + {"metadata": {"total": 1, "extracted_at": "2026-09-12T00:00:00Z"}, "packages": [{}, {}]}, + {"metadata": {"total": True, "extracted_at": "2026-09-12T00:00:00Z"}, "packages": [{}]}, + {"metadata": {"total": 1, "extracted_at": "2026-09-12"}, "packages": [{}]}, + {"metadata": {"total": 1, "extracted_at": "bad"}, "packages": [{}]}, + {"metadata": {"total": 1, "extracted_at": "2026-09-12T00:00:00Z"}, "packages": [None]}): + with self.subTest(catalog=catalog): + self.catalog = catalog + self.save_catalog() + with self.assertRaises(ValueError): + self.build() + self.assertFalse(self.output.exists()) + + def test_sitemap_uses_catalog_date_and_omits_unknown_agent_date(self): + self.build() + entries = ET.parse(self.output / "sitemap.xml").findall(f"{{{SITEMAP_NS}}}url") + self.assertEqual(entries[0].find(f"{{{SITEMAP_NS}}}lastmod").text, "2026-09-12") + self.assertIsNone(entries[1].find(f"{{{SITEMAP_NS}}}lastmod")) + self.assertEqual({child.tag.rsplit("}", 1)[-1] for entry in entries for child in entry}, {"loc", "lastmod"}) + + def test_sitemap_uses_actual_page_dates_not_the_build_date(self): + dates = [datetime(2026, 9, 14, tzinfo=timezone.utc), datetime(2026, 7, 1, tzinfo=timezone.utc)] + with patch("build_site.git_modified", side_effect=dates): + build_site(self.source, self.output) + values = [node.text for node in ET.parse(self.output / "sitemap.xml").findall(f".//{{{SITEMAP_NS}}}lastmod")] + self.assertEqual(values, ["2026-09-14", "2026-07-01"]) + self.assertIsNone(git_modified(self.source, ["index.html"])) + + def test_assets_catalog_and_preview_dimensions_are_preserved(self): + self.build() + for filename in (*ASSETS, "packages.json"): + if filename not in ("agent-access.html", "llms.txt"): + self.assertEqual((self.source / filename).read_bytes(), (self.output / filename).read_bytes()) + for original in (self.source / "vendor").rglob("*"): + if original.is_file(): + self.assertEqual(original.read_bytes(), (self.output / original.relative_to(self.source)).read_bytes()) + self.assertTrue((self.output / ".nojekyll").is_file()) + png = (self.output / "og-image.png").read_bytes() + self.assertEqual(png[:8], b"\x89PNG\r\n\x1a\n") + self.assertEqual(struct.unpack(">II", png[16:24]), (1200, 630)) + ET.parse(self.output / "og-image.svg") + + def test_missing_template_marker_fails_before_writing(self): + index = self.source / "index.html" + index.write_text(index.read_text().replace('id="catalog-summary"', 'id="removed"')) + with self.assertRaisesRegex(ValueError, "catalog-summary"): + self.build() + self.assertFalse(self.output.exists()) + + +if __name__ == "__main__": + unittest.main()