mirror of
https://github.com/sbroenne/mcp-server-excel.git
synced 2026-09-19 07:53:08 +08:00
441f4c27dc
* Set explicit production render quality Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Publish the high-quality YouTube intro on the website Replace the old upload links with the verified new public video and document the complete publication checklist. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Stefan Broenner <stbrnner@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1547 lines
57 KiB
Python
1547 lines
57 KiB
Python
"""MkDocs build hook: generate documentation pages from canonical repo sources.
|
|
|
|
This preserves the project's single-source-of-truth design: several site pages
|
|
are generated from the authoritative Markdown files elsewhere in the repo
|
|
(README files, FEATURES.md, CHANGELOG.md, docs/*) so the website can never
|
|
drift from the real docs. It is the MkDocs equivalent of the old Jekyll
|
|
``build.sh`` script.
|
|
|
|
Generated files are written to ``gh-pages/_generated/`` (git-ignored, and
|
|
deliberately outside ``docs_dir``) and pulled into the thin wrapper pages under
|
|
``docs/`` via the ``pymdownx.snippets`` ``--8<--`` include syntax. Regeneration
|
|
happens automatically on every ``mkdocs build`` / ``mkdocs serve`` via the
|
|
``on_pre_build`` event.
|
|
|
|
Two smaller jobs live here as well:
|
|
|
|
* ``on_env`` hands ``overrides/sitemap.xml`` the git commit date behind every
|
|
page, so ``<lastmod>`` reflects real content changes rather than the build
|
|
date, plus the home page's video metadata.
|
|
* ``on_post_page`` gives Material's search dialog an accessible name. The logo
|
|
and progress-bar equivalents are declarative partials under ``overrides/``;
|
|
``audit_site.py`` fails the build if any of the three stops applying.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import posixpath
|
|
import re
|
|
import subprocess
|
|
from datetime import datetime, timedelta
|
|
from html import escape
|
|
from pathlib import Path
|
|
|
|
log = logging.getLogger("mkdocs.hooks.generate")
|
|
|
|
# Home-page intro video. MkDocs' built-in sitemap is a plain URL sitemap and has
|
|
# no notion of embedded media, so overrides/sitemap.xml renders a Google
|
|
# video-sitemap <video:video> block into the home page's <url> entry. Keep these
|
|
# fields in sync with the VideoObject JSON-LD in docs/index.md.
|
|
VIDEO = {
|
|
"page_url": "https://excelmcpserver.dev/",
|
|
"thumbnail": "https://i.ytimg.com/vi/wbw3-hPcE2o/maxresdefault.jpg",
|
|
"title": "Excel MCP Server: Real Excel Automation for AI Agents",
|
|
"description": (
|
|
"Learn what Excel MCP Server is, when to use it, and how AI agents automate "
|
|
"Power Query, DAX, PivotTables, VBA, Python, and calculations through real "
|
|
"Microsoft Excel."
|
|
),
|
|
"player_loc": "https://www.youtube.com/embed/wbw3-hPcE2o",
|
|
"duration": "121",
|
|
"publication_date": "2026-09-12T07:07:06-07:00",
|
|
}
|
|
|
|
# gh-pages/hooks.py -> gh-pages/ -> repo root
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
# Deliberately OUTSIDE docs_dir. When the generated files lived in
|
|
# docs/_generated/, every build rewrote files inside the directory `mkdocs
|
|
# serve` watches, so a single edit put the dev server into an endless
|
|
# rebuild loop. `.` is a snippets base_path, so the `--8<-- "_generated/..."`
|
|
# includes in the wrapper pages resolve here unchanged.
|
|
GEN_DIR = Path(__file__).resolve().parent / "_generated"
|
|
|
|
GITHUB_BLOB = "https://github.com/sbroenne/mcp-server-excel/blob/main/"
|
|
GITHUB_TREE = "https://github.com/sbroenne/mcp-server-excel/tree/main/"
|
|
|
|
# Repo-relative paths that have a dedicated site page: rewrite links to them so
|
|
# they resolve on the website instead of 404-ing.
|
|
SITE_PAGE_MAP = {
|
|
"FEATURES.md": "/features/",
|
|
".github/usage-analytics.json": "/usage-analytics/",
|
|
"docs/features/DATA-ANALYTICS.md": "/features/data-analytics/",
|
|
"docs/features/CELLS-WORKBOOKS.md": "/features/cells-workbooks/",
|
|
"docs/features/CHARTS-VISUALS.md": "/features/charts-visuals/",
|
|
"docs/features/AUTOMATION-ADVANCED.md": "/features/automation-advanced/",
|
|
"CHANGELOG.md": "/changelog/",
|
|
"docs/INSTALLATION.md": "/installation/",
|
|
"docs/INSTALLATION-MCP-SERVER.md": "/installation-mcp-server/",
|
|
"docs/INSTALLATION-CLI.md": "/installation-cli/",
|
|
"docs/ARCHITECTURE.md": "/architecture/",
|
|
"docs/USE-CASES.md": "/use-cases/",
|
|
"docs/guides/README.md": "/guides/",
|
|
"docs/guides/REFRESH-POWER-QUERY.md": "/guides/refresh-power-query/",
|
|
"docs/guides/AUTOMATE-PIVOTTABLES.md": "/guides/automate-pivottables/",
|
|
"docs/guides/RUN-VBA-MACROS.md": "/guides/run-vba-macros/",
|
|
"docs/guides/QUERY-DATA-MODEL-WITH-DAX.md": "/guides/query-data-model-with-dax/",
|
|
"docs/guides/EXCEL-COM-VS-FILE-PARSERS.md": "/guides/excel-automation-vs-file-parsers/",
|
|
"docs/CONTRIBUTING.md": "/contributing/",
|
|
"SECURITY.md": "/security/",
|
|
"PRIVACY.md": "/privacy/",
|
|
"src/ExcelMcp.McpServer/README.md": "/mcp-server/",
|
|
"src/ExcelMcp.CLI/README.md": "/cli/",
|
|
"skills/README.md": "/skills/",
|
|
}
|
|
|
|
_MD_LINK = re.compile(r"(?<!!)\[([^\]]+)\]\(([^)\s]+)\)")
|
|
|
|
SITE_URL = "https://excelmcpserver.dev/"
|
|
|
|
# Raw Markdown of every built page, captured in on_page_markdown with --8<--
|
|
# includes resolved, and emitted in on_post_build as /llms-full.txt plus one
|
|
# Markdown mirror per page. Keyed by the page's site path.
|
|
_PAGE_MARKDOWN: dict[str, dict[str, str]] = {}
|
|
|
|
_SNIPPET = re.compile(r'^[ \t]*(?:-{2,}8<-{2,})[ \t]+"([^"]+)"[ \t]*$', re.MULTILINE)
|
|
_FRONTMATTER = re.compile(r"\A---\r?\n.*?\r?\n---\r?\n", re.DOTALL)
|
|
|
|
FEATURE_SOURCES = {
|
|
"features-data.md": "docs/features/DATA-ANALYTICS.md",
|
|
"features-workbooks.md": "docs/features/CELLS-WORKBOOKS.md",
|
|
"features-visualization.md": "docs/features/CHARTS-VISUALS.md",
|
|
"features-automation.md": "docs/features/AUTOMATION-ADVANCED.md",
|
|
}
|
|
|
|
# Canonical task guides -> intent-focused website pages. Same contract as the
|
|
# feature references: the wrapper owns presentation and SEO metadata only.
|
|
GUIDE_SOURCES = {
|
|
"guides-index.md": "docs/guides/README.md",
|
|
"guides-refresh-power-query.md": "docs/guides/REFRESH-POWER-QUERY.md",
|
|
"guides-automate-pivottables.md": "docs/guides/AUTOMATE-PIVOTTABLES.md",
|
|
"guides-run-vba-macros.md": "docs/guides/RUN-VBA-MACROS.md",
|
|
"guides-query-data-model-with-dax.md": "docs/guides/QUERY-DATA-MODEL-WITH-DAX.md",
|
|
"guides-excel-com-vs-file-parsers.md": "docs/guides/EXCEL-COM-VS-FILE-PARSERS.md",
|
|
}
|
|
|
|
|
|
# skills/shared/*.md: the expert reference corpus shipped inside the skill
|
|
# packages and MCP prompts. Published verbatim so the site and the agent
|
|
# guidance can never disagree. Value = (output name, page title).
|
|
SKILL_SOURCES = {
|
|
"workflows.md": ("skills-workflows.md", "Key Constraints & Sequencing"),
|
|
"behavioral-rules.md": ("skills-behavioral-rules.md", "Behavioral Rules"),
|
|
"anti-patterns.md": ("skills-anti-patterns.md", "Anti-Patterns to Avoid"),
|
|
"gotchas.md": ("skills-gotchas.md", "Gotchas & Known Limits"),
|
|
"excel_agent_mode.md": ("skills-agent-mode.md", "Agent Mode in Excel"),
|
|
"workbook.md": ("skills-workbook.md", "Workbook Lifecycle"),
|
|
"worksheet.md": ("skills-worksheet.md", "Worksheet Operations"),
|
|
"range.md": ("skills-range.md", "Ranges, Number Formats & Formatting"),
|
|
"table.md": ("skills-table.md", "Excel Tables"),
|
|
"powerquery.md": ("skills-powerquery.md", "Power Query"),
|
|
"m-code-syntax.md": ("skills-m-code-syntax.md", "M Code Syntax"),
|
|
"datamodel.md": ("skills-datamodel.md", "Data Model & DAX"),
|
|
"dmv-reference.md": ("skills-dmv-reference.md", "DMV Query Reference"),
|
|
"pivottable.md": ("skills-pivottable.md", "PivotTables"),
|
|
"querytable.md": ("skills-querytable.md", "QueryTables"),
|
|
"analysis.md": ("skills-analysis.md", "What-If Analysis"),
|
|
"chart.md": ("skills-chart.md", "Charts"),
|
|
"conditionalformat.md": ("skills-conditionalformat.md", "Conditional Formatting"),
|
|
"slicer.md": ("skills-slicer.md", "Slicers"),
|
|
"drawing.md": ("skills-drawing.md", "Drawing Objects"),
|
|
"screenshot.md": ("skills-screenshot.md", "Screenshots & Visual Verification"),
|
|
"dashboard.md": ("skills-dashboard.md", "Dashboards & Reports"),
|
|
"window.md": ("skills-window.md", "Window Management"),
|
|
"xmlmap.md": ("skills-xmlmap.md", "XML Maps"),
|
|
}
|
|
|
|
_SKILL_SLUGS = {
|
|
name: output.removeprefix("skills-").removesuffix(".md")
|
|
for name, (output, _title) in SKILL_SOURCES.items()
|
|
}
|
|
SITE_PAGE_MAP.update(
|
|
{f"skills/shared/{name}": f"/reference/{slug}/" for name, slug in _SKILL_SLUGS.items()}
|
|
)
|
|
|
|
|
|
def _rewrite_links(text: str, source_rel: str) -> str:
|
|
"""Resolve links in pulled-in content so they work on the site.
|
|
|
|
Two cases:
|
|
|
|
- Repo-relative links: rewritten to the published page when we publish one,
|
|
otherwise to an absolute GitHub URL.
|
|
- Absolute GitHub URLs into this repo: rewritten *back* to the published
|
|
page when we publish one. Sources that are also rendered outside GitHub -
|
|
the NuGet package READMEs - have to spell links out absolutely, because
|
|
NuGet.org resolves relative links against the package root and they 404.
|
|
Without this the website would link out to GitHub for pages it publishes
|
|
itself.
|
|
|
|
External links, anchors and site-absolute links are left alone.
|
|
"""
|
|
source_dir = posixpath.dirname(source_rel)
|
|
|
|
def repl(match: re.Match) -> str:
|
|
label, url = match.group(1), match.group(2)
|
|
|
|
for prefix in (GITHUB_BLOB, GITHUB_TREE):
|
|
if url.startswith(prefix):
|
|
remainder = url[len(prefix) :]
|
|
target, _, anchor = remainder.partition("#")
|
|
anchor = f"#{anchor}" if anchor else ""
|
|
if target.rstrip("/") in SITE_PAGE_MAP:
|
|
return f"[{label}]({SITE_PAGE_MAP[target.rstrip('/')]}{anchor})"
|
|
return match.group(0)
|
|
|
|
if url.startswith(("http://", "https://", "#", "/", "mailto:", "<")):
|
|
return match.group(0)
|
|
|
|
anchor = ""
|
|
target = url
|
|
if "#" in target:
|
|
target, anchor = target.split("#", 1)
|
|
anchor = "#" + anchor
|
|
if target == "":
|
|
return match.group(0) # pure in-page anchor
|
|
|
|
resolved = posixpath.normpath(posixpath.join(source_dir, target))
|
|
if resolved.startswith(".."):
|
|
return match.group(0) # points outside the repo; leave as-is
|
|
|
|
if resolved in SITE_PAGE_MAP:
|
|
return f"[{label}]({SITE_PAGE_MAP[resolved]}{anchor})"
|
|
|
|
base = GITHUB_TREE if url.endswith("/") else GITHUB_BLOB
|
|
return f"[{label}]({base}{resolved}{anchor})"
|
|
|
|
return _MD_LINK.sub(repl, text)
|
|
|
|
|
|
def _strip_header(
|
|
text: str,
|
|
*,
|
|
drop_prefixes: tuple[str, ...] = (),
|
|
end_on_blank: bool = False,
|
|
end_on_hr: bool = False,
|
|
demote_h1: bool = False,
|
|
) -> str:
|
|
"""Drop the leading H1 title block from a source file, optionally demoting
|
|
any remaining H1 headings to H2.
|
|
|
|
Mirrors the awk transforms in the previous Jekyll ``build.sh``:
|
|
- the first ``# Title`` line is always dropped, and header mode begins;
|
|
- while in the header, lines starting with any ``drop_prefixes`` are dropped;
|
|
- the header ends on the first blank line (``end_on_blank``) or ``---`` rule
|
|
(``end_on_hr``); leading blank lines before content are also dropped;
|
|
- when ``demote_h1`` is set, any later ``# `` heading becomes ``## ``.
|
|
"""
|
|
in_header = False
|
|
header_done = False
|
|
out: list[str] = []
|
|
|
|
for line in text.splitlines():
|
|
if not header_done and line.startswith("# "):
|
|
in_header = True
|
|
continue
|
|
if in_header:
|
|
if any(line.startswith(p) for p in drop_prefixes):
|
|
continue
|
|
if end_on_hr and line.startswith("---"):
|
|
in_header = False
|
|
header_done = True
|
|
continue
|
|
if line.strip() == "":
|
|
if end_on_blank:
|
|
in_header = False
|
|
header_done = True
|
|
continue
|
|
# Any other lingering header line is dropped.
|
|
continue
|
|
if not header_done and line.strip() == "":
|
|
# Skip leading blank lines before real content begins.
|
|
continue
|
|
header_done = True
|
|
if demote_h1 and line.startswith("# "):
|
|
line = "#" + line # "# " -> "## "
|
|
out.append(line)
|
|
|
|
return "\n".join(out).strip() + "\n"
|
|
|
|
|
|
def _add_stable_feature_anchors(text: str) -> str:
|
|
"""Give feature headings stable IDs that do not include operation counts."""
|
|
heading = re.compile(r"^## (?P<title>.+?) \(\d+ operations\)$", re.MULTILINE)
|
|
|
|
def replace(match: re.Match) -> str:
|
|
title = match.group("title")
|
|
slug = re.sub(r"[^\w\s-]", "", title, flags=re.UNICODE).strip().lower()
|
|
slug = re.sub(r"[-\s]+", "-", slug)
|
|
return f"{match.group(0)} {{ #{slug} }}"
|
|
|
|
return heading.sub(replace, text)
|
|
|
|
|
|
def _read(rel: str) -> str:
|
|
path = REPO_ROOT / rel
|
|
if not path.is_file():
|
|
raise FileNotFoundError(f"Source doc not found: {path}")
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _write(name: str, source_rel: str, content: str) -> None:
|
|
GEN_DIR.mkdir(parents=True, exist_ok=True)
|
|
content = _rewrite_links(content, source_rel)
|
|
(GEN_DIR / name).write_text(content, encoding="utf-8")
|
|
MIRROR_SOURCES[name] = source_rel
|
|
log.info("generated _generated/%s", name)
|
|
|
|
|
|
def _analytics_cell(value: object) -> str:
|
|
"""Format a validated aggregate value for a Markdown table."""
|
|
if isinstance(value, float):
|
|
text = f"{value:,.2f}".rstrip("0").rstrip(".")
|
|
elif isinstance(value, int):
|
|
text = f"{value:,}"
|
|
else:
|
|
text = str(value)
|
|
return text.replace("|", r"\|").replace("\r", " ").replace("\n", " ")
|
|
|
|
|
|
def _analytics_table(
|
|
headings: tuple[str, ...],
|
|
fields: tuple[str, ...],
|
|
rows: list[dict[str, object]],
|
|
) -> str:
|
|
lines = [
|
|
"| " + " | ".join(headings) + " |",
|
|
"|" + "|".join("---" for _ in headings) + "|",
|
|
]
|
|
for row in rows:
|
|
lines.append(
|
|
"| "
|
|
+ " | ".join(_analytics_cell(row[field]) for field in fields)
|
|
+ " |"
|
|
)
|
|
return "\n".join(lines)
|
|
|
|
|
|
_ANALYTICS_FAMILY_NAMES = {
|
|
"range": "Reading and writing cells",
|
|
"file": "Managing workbooks",
|
|
"range_format": "Formatting cells",
|
|
"vba": "Running and editing macros",
|
|
"worksheet": "Working with worksheets",
|
|
"powerquery": "Refreshing and checking data",
|
|
"range_edit": "Finding, sorting, and editing cells",
|
|
"calculation_mode": "Calculating formulas",
|
|
"screenshot": "Taking screenshots",
|
|
"table": "Working with Excel tables",
|
|
"datamodel": "Working with the Data Model",
|
|
}
|
|
|
|
_ANALYTICS_HERO_FEATURE_NAMES = {
|
|
"power-query": "Power Query & M code",
|
|
"power-pivot-dax": "Power Pivot & DAX",
|
|
"pivottables-charts": "PivotTables & charts",
|
|
"tables-ranges": "Tables & ranges",
|
|
"vba": "VBA macros",
|
|
"worksheets-connections": "Worksheets & connections",
|
|
"agent-mode": "Agent mode",
|
|
"python-in-excel": "Python in Excel",
|
|
"other": "Other features",
|
|
}
|
|
|
|
_ANALYTICS_OPERATION_NAMES = {
|
|
"range/get-values": "Read cell values",
|
|
"range/set-values": "Write cell values",
|
|
"file/open": "Open a workbook",
|
|
"file/close": "Close a workbook",
|
|
"range/set-formulas": "Write formulas",
|
|
"range/get-used-range": "Find the used area",
|
|
"range_format/format-range": "Format cells",
|
|
"range/get-formulas": "Read formulas",
|
|
"file/list": "List open workbooks",
|
|
"worksheet/list": "List worksheets",
|
|
"screenshot/capture": "Take a screenshot",
|
|
"range_format/set-column-width": "Set column width",
|
|
"vba/run": "Run a macro",
|
|
"range_edit/find": "Find cells",
|
|
"range/set-number-format": "Set number format",
|
|
}
|
|
|
|
_ANALYTICS_FAILURE_CLASS_NAMES = {
|
|
"expected-negative": "Expected validation or state result",
|
|
"input-state": "Input or workbook state",
|
|
"external-dependency": "External dependency",
|
|
"timeout-cancellation": "Timeout or cancellation",
|
|
"excel-runtime": "Excel runtime",
|
|
"internal-product-fault": "Excel MCP product fault",
|
|
"unclassified": "Unclassified failure",
|
|
}
|
|
|
|
|
|
def _analytics_name(value: object, names: dict[str, str]) -> str:
|
|
"""Replace an internal action name with a reader-friendly label."""
|
|
raw = str(value)
|
|
if raw in names:
|
|
return names[raw]
|
|
return raw.replace("/", " ").replace("_", " ").replace("-", " ").title()
|
|
|
|
|
|
def _analytics_bar_chart(
|
|
rows: list[dict[str, object]],
|
|
*,
|
|
label_field: str,
|
|
value_field: str,
|
|
value_suffix: str = "",
|
|
) -> str:
|
|
"""Render an accessible horizontal comparison chart."""
|
|
maximum = max((float(row[value_field]) for row in rows), default=0)
|
|
lines = ['<div class="analytics-bars" role="list">']
|
|
for row in rows:
|
|
value = float(row[value_field])
|
|
width = 0 if maximum == 0 else max(2, value / maximum * 100)
|
|
label = escape(str(row[label_field]))
|
|
display_value = f"{_analytics_cell(row[value_field])}{value_suffix}"
|
|
lines.extend(
|
|
[
|
|
' <div class="analytics-bars__row" role="listitem">',
|
|
' <div class="analytics-bars__label">',
|
|
f" <span>{label}</span><strong>{escape(display_value)}</strong>",
|
|
" </div>",
|
|
' <div class="analytics-bars__track" aria-hidden="true">',
|
|
f' <span style="width: {width:.2f}%"></span>',
|
|
" </div>",
|
|
" </div>",
|
|
]
|
|
)
|
|
lines.append("</div>")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _analytics_week_chart(
|
|
rows: list[dict[str, object]],
|
|
*,
|
|
value_field: str,
|
|
title: str,
|
|
) -> str:
|
|
"""Render weekly values as an accessible compact bar chart."""
|
|
maximum = max((float(row[value_field]) for row in rows), default=0)
|
|
midpoint = maximum / 2
|
|
lines = [
|
|
'<div class="analytics-week-chart" role="group" '
|
|
f'aria-label="{escape(title)}">',
|
|
f" <strong>{escape(title)}</strong>",
|
|
' <div class="analytics-week-chart__body">',
|
|
' <div class="analytics-week-chart__y-axis" aria-hidden="true">',
|
|
f" <span>{escape(_analytics_cell(maximum))}</span>",
|
|
f" <span>{escape(_analytics_cell(midpoint))}</span>",
|
|
" <span>0</span>",
|
|
" </div>",
|
|
' <div class="analytics-week-chart__plot" role="list">',
|
|
]
|
|
for row in rows:
|
|
value = float(row[value_field])
|
|
height = 0 if maximum == 0 else max(2, value / maximum * 100)
|
|
week = datetime.fromisoformat(str(row["week"]))
|
|
label = week.strftime("%b %d")
|
|
display_value = _analytics_cell(row[value_field])
|
|
lines.extend(
|
|
[
|
|
' <div class="analytics-week-chart__week" role="listitem" '
|
|
f'aria-label="Week of {escape(label)}: {escape(display_value)}">',
|
|
f' <span style="height: {height:.2f}%" aria-hidden="true"></span>',
|
|
f" <small>{escape(label)}</small>",
|
|
" </div>",
|
|
]
|
|
)
|
|
lines.extend([" </div>", " </div>", "</div>"])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _analytics_version_chart(rows: list[dict[str, object]]) -> str:
|
|
"""Render weekly release adoption as a 100% stacked column chart."""
|
|
palette = (
|
|
"#4051b5",
|
|
"#008b8b",
|
|
"#d97706",
|
|
"#db2777",
|
|
"#7c3aed",
|
|
"#15803d",
|
|
"#dc2626",
|
|
"#64748b",
|
|
"#0891b2",
|
|
)
|
|
weeks: dict[str, dict[str, dict[str, object]]] = {}
|
|
totals: dict[str, int] = {}
|
|
for row in rows:
|
|
week = str(row["week"])
|
|
version = str(row["version"])
|
|
weeks.setdefault(week, {})[version] = row
|
|
totals[version] = totals.get(version, 0) + int(row["users"])
|
|
|
|
versions = sorted(
|
|
totals,
|
|
key=lambda version: (version == "Other", -totals[version], version),
|
|
)
|
|
colors = {
|
|
version: palette[index % len(palette)]
|
|
for index, version in enumerate(versions)
|
|
}
|
|
lines = [
|
|
'<div class="analytics-version-chart" role="group" '
|
|
'aria-label="Share of users by release each week">',
|
|
' <div class="analytics-version-chart__legend" aria-hidden="true">',
|
|
]
|
|
for version in versions:
|
|
lines.append(
|
|
' <span><i style="background: '
|
|
f'{colors[version]}"></i>{escape(version)}</span>'
|
|
)
|
|
lines.extend(
|
|
[
|
|
" </div>",
|
|
' <div class="analytics-version-chart__body">',
|
|
' <div class="analytics-version-chart__y-axis" aria-hidden="true">',
|
|
" <span>100%</span>",
|
|
" <span>50%</span>",
|
|
" <span>0%</span>",
|
|
" </div>",
|
|
' <div class="analytics-version-chart__plot" role="list">',
|
|
]
|
|
)
|
|
for week_value in sorted(weeks):
|
|
week = datetime.fromisoformat(week_value)
|
|
label = week.strftime("%b %d")
|
|
entries = weeks[week_value]
|
|
summary = ", ".join(
|
|
f"{version}: {_analytics_cell(entries[version]['sharePct'])}%"
|
|
for version in versions
|
|
if version in entries
|
|
)
|
|
lines.extend(
|
|
[
|
|
' <div class="analytics-version-chart__week" role="listitem" '
|
|
f'aria-label="Week of {escape(label)}. {escape(summary)}">',
|
|
' <div class="analytics-version-chart__stack" aria-hidden="true">',
|
|
]
|
|
)
|
|
for version in versions:
|
|
if version not in entries:
|
|
continue
|
|
row = entries[version]
|
|
share = float(row["sharePct"])
|
|
title = (
|
|
f"{version}: {_analytics_cell(row['sharePct'])}% "
|
|
f"({_analytics_cell(row['users'])} users)"
|
|
)
|
|
lines.append(
|
|
f' <span title="{escape(title)}" '
|
|
f'style="height: {share:.2f}%; background: {colors[version]}"></span>'
|
|
)
|
|
lines.extend(
|
|
[
|
|
" </div>",
|
|
f" <small>{escape(label)}</small>",
|
|
" </div>",
|
|
]
|
|
)
|
|
lines.extend([" </div>", " </div>", "</div>"])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _render_usage_analytics() -> str:
|
|
source_rel = ".github/usage-analytics.json"
|
|
report = json.loads(_read(source_rel))
|
|
if report.get("schemaVersion") != 2:
|
|
raise ValueError("usage analytics has an unsupported schema version")
|
|
interpretation = report.get("interpretation")
|
|
if not isinstance(interpretation, str) or not interpretation.strip():
|
|
raise ValueError("usage analytics is missing its validated interpretation")
|
|
|
|
summary = report["summary"]
|
|
comparison = report["comparison"]
|
|
generated = datetime.fromisoformat(report["generatedAtUtc"].replace("Z", "+00:00"))
|
|
reporting_days = int(report["windows"]["reportingDays"])
|
|
comparison_days = int(report["windows"]["comparisonDays"])
|
|
reporting_start = generated - timedelta(days=reporting_days)
|
|
current_start = generated - timedelta(days=comparison_days)
|
|
previous_start = generated - timedelta(days=comparison_days * 2)
|
|
categorized_reliability_since = datetime.fromisoformat(
|
|
report["windows"]["categorizedReliabilitySinceUtc"].replace("Z", "+00:00")
|
|
)
|
|
categorized_reliability_version = report["windows"][
|
|
"categorizedReliabilityMinimumVersion"
|
|
]
|
|
date_format = "%b %d, %Y"
|
|
|
|
hero_rows = [
|
|
{
|
|
**row,
|
|
"friendlyName": _analytics_name(
|
|
row["name"], _ANALYTICS_HERO_FEATURE_NAMES
|
|
),
|
|
"share": f"{_analytics_cell(row['sharePct'])}%",
|
|
}
|
|
for row in report["heroFeatures"]
|
|
]
|
|
operation_rows = [
|
|
{
|
|
**row,
|
|
"friendlyName": _analytics_name(row["name"], _ANALYTICS_OPERATION_NAMES),
|
|
}
|
|
for row in report["operations"]
|
|
]
|
|
reliability_rows = [
|
|
{
|
|
**row,
|
|
"friendlyName": _analytics_name(row["name"], _ANALYTICS_OPERATION_NAMES),
|
|
"failureRateDisplay": f"{_analytics_cell(row['failureRate'])}%",
|
|
}
|
|
for row in report["reliability"]
|
|
]
|
|
failure_class_rows = [
|
|
{
|
|
**row,
|
|
"friendlyName": _analytics_name(
|
|
row["name"], _ANALYTICS_FAILURE_CLASS_NAMES
|
|
),
|
|
}
|
|
for row in report["failureClasses"]
|
|
]
|
|
release_rows = [
|
|
{
|
|
**row,
|
|
"failureRateDisplay": f"{_analytics_cell(row['failureRate'])}%",
|
|
}
|
|
for row in report["versionReliability"]
|
|
]
|
|
comparison_rows = [
|
|
{
|
|
"metric": "Users",
|
|
"current": comparison["currentUsers"],
|
|
"previous": comparison["previousUsers"],
|
|
"change": f"{comparison['userChangePct']}%",
|
|
},
|
|
{
|
|
"metric": "Actions",
|
|
"current": comparison["currentInvocations"],
|
|
"previous": comparison["previousInvocations"],
|
|
"change": f"{comparison['invocationChangePct']}%",
|
|
},
|
|
]
|
|
sections = [
|
|
"Excel MCP Server lets GitHub Copilot, Claude, and other AI assistants "
|
|
"automate the real Microsoft Excel application. This public report shows "
|
|
"how the open-source project is used and where reliability can improve.",
|
|
"",
|
|
"New to the project? [Install Excel MCP Server](/installation/) to get started.",
|
|
"",
|
|
"!!! info \"Anonymous public report\"\n"
|
|
" This page shows broad usage patterns, not individual activity. "
|
|
"Names, file details, locations, and the content of workbooks are never "
|
|
"included.",
|
|
"",
|
|
f"**Last updated:** {generated.strftime(date_format)} \n"
|
|
f"**Period covered:** {reporting_start.strftime(date_format)} to "
|
|
f"{generated.strftime(date_format)}",
|
|
"",
|
|
"## At a glance",
|
|
"",
|
|
'<div class="grid cards analytics-cards" markdown>',
|
|
"",
|
|
f"- :material-account-group: **{_analytics_cell(summary['users'])} users**",
|
|
"",
|
|
f" Used Excel MCP during the last {reporting_days} days.",
|
|
"",
|
|
f"- :material-lightning-bolt: **{_analytics_cell(summary['toolInvocations'])} actions**",
|
|
"",
|
|
" Recorded across workbooks, cells, data, charts, and automation.",
|
|
"",
|
|
f"- :material-calendar-refresh: **{_analytics_cell(summary['repeatUserRate'])}% returned**",
|
|
"",
|
|
" Used Excel MCP on at least two different days.",
|
|
"",
|
|
"</div>",
|
|
"",
|
|
f"## Usage over the last {report['windows']['trendWeeks']} complete weeks",
|
|
"",
|
|
"Each bar is one full week, which makes changes easier to compare.",
|
|
"",
|
|
_analytics_week_chart(
|
|
report["weekly"],
|
|
value_field="users",
|
|
title="Users each week",
|
|
),
|
|
"",
|
|
_analytics_week_chart(
|
|
report["weekly"],
|
|
value_field="actions",
|
|
title="Actions each week",
|
|
),
|
|
"",
|
|
"## Release upgrades over time",
|
|
"",
|
|
"Each column is one week; the final column is the current week so far. A "
|
|
"user appears once, under the latest release they used that week. This "
|
|
"makes it easy to see newer releases replace older ones without overall "
|
|
"user growth changing the scale. Less common releases are grouped as "
|
|
"**Other**.",
|
|
"",
|
|
_analytics_version_chart(report["versionAdoption"]),
|
|
"",
|
|
"## The latest two weeks",
|
|
"",
|
|
f"The latest period is **{current_start.strftime(date_format)} to "
|
|
f"{generated.strftime(date_format)}**. It is compared with "
|
|
f"**{previous_start.strftime(date_format)} to "
|
|
f"{current_start.strftime(date_format)}**.",
|
|
"",
|
|
_analytics_table(
|
|
("Measure", f"Latest {comparison_days} days", f"Previous {comparison_days} days", "Change"),
|
|
("metric", "current", "previous", "change"),
|
|
comparison_rows,
|
|
),
|
|
"",
|
|
"## What the numbers tell us",
|
|
"",
|
|
"!!! note \"Summary written by GitHub Copilot\"\n"
|
|
" Copilot reads only the anonymous totals used to build this page. Its "
|
|
"summary is checked automatically so it cannot add private details or "
|
|
"numbers that are not in the report.",
|
|
"",
|
|
interpretation.strip(),
|
|
"",
|
|
"## What people use most",
|
|
"",
|
|
"The bars group actions by the main features highlighted on the Excel MCP "
|
|
"homepage. The percentage is each feature's share of meaningful actions. "
|
|
"Smaller capabilities are grouped as **Other features**.",
|
|
"",
|
|
_analytics_bar_chart(
|
|
hero_rows,
|
|
label_field="friendlyName",
|
|
value_field="sharePct",
|
|
value_suffix="%",
|
|
),
|
|
"",
|
|
_analytics_table(
|
|
("Homepage feature", "Actions", "Users", "Share"),
|
|
("friendlyName", "invocations", "users", "share"),
|
|
hero_rows,
|
|
),
|
|
"",
|
|
"## Most common actions",
|
|
"",
|
|
_analytics_table(
|
|
("Action", "Times used", "Users"),
|
|
("friendlyName", "invocations", "users"),
|
|
operation_rows[:8],
|
|
),
|
|
"",
|
|
]
|
|
sections.extend(
|
|
[
|
|
"## Reliability measurement",
|
|
"",
|
|
f"Outcome classification starts with release "
|
|
f"**{categorized_reliability_version}** from "
|
|
f"**{categorized_reliability_since.strftime(date_format)}**. Earlier "
|
|
"rows did not contain these labels and are not guessed or rewritten.",
|
|
"",
|
|
"A negative diagnostic result, such as finding that a workbook cannot "
|
|
"be opened, is counted as an expected result rather than a product "
|
|
"failure. Failures are grouped using fixed labels supplied by the "
|
|
"software. Unknown labels remain visible as **Unclassified failure**.",
|
|
"",
|
|
]
|
|
)
|
|
if failure_class_rows:
|
|
sections.extend(
|
|
[
|
|
"### Outcomes and failure classes",
|
|
"",
|
|
_analytics_bar_chart(
|
|
failure_class_rows,
|
|
label_field="friendlyName",
|
|
value_field="actions",
|
|
),
|
|
"",
|
|
_analytics_table(
|
|
("Outcome or failure class", "Actions", "Users"),
|
|
("friendlyName", "actions", "users"),
|
|
failure_class_rows,
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
if reliability_rows:
|
|
sections.extend(
|
|
[
|
|
"### Reliability by action",
|
|
"",
|
|
_analytics_table(
|
|
(
|
|
"Action",
|
|
"Actions",
|
|
"Expected negative",
|
|
"Failures",
|
|
"Failure rate",
|
|
"Users",
|
|
),
|
|
(
|
|
"friendlyName",
|
|
"actions",
|
|
"expectedNegatives",
|
|
"failures",
|
|
"failureRateDisplay",
|
|
"users",
|
|
),
|
|
reliability_rows[:15],
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
if release_rows:
|
|
sections.extend(
|
|
[
|
|
"### Reliability by release",
|
|
"",
|
|
"This comparison can reveal a problem introduced in a release. It "
|
|
"is not a direct quality score: different releases may be used for "
|
|
"different kinds of work. The action count shows how much data each "
|
|
"rate is based on.",
|
|
"",
|
|
_analytics_table(
|
|
(
|
|
"Release",
|
|
"Actions",
|
|
"Expected negative",
|
|
"Failures",
|
|
"Failure rate",
|
|
"Users",
|
|
),
|
|
(
|
|
"version",
|
|
"actions",
|
|
"expectedNegatives",
|
|
"failures",
|
|
"failureRateDisplay",
|
|
"users",
|
|
),
|
|
release_rows[:15],
|
|
),
|
|
"",
|
|
]
|
|
)
|
|
sections.extend(
|
|
[
|
|
"## Problems we are watching",
|
|
"",
|
|
]
|
|
)
|
|
exceptions = report["exceptions"]
|
|
if exceptions:
|
|
total_exceptions = sum(int(row["exceptions"]) for row in exceptions)
|
|
sections.append(
|
|
f"Excel MCP reported **{_analytics_cell(total_exceptions)} background "
|
|
"task problems** during this period. These reports came from at least "
|
|
f"**{_analytics_cell(max(int(row['users']) for row in exceptions))} "
|
|
"users**. They are not the same as failed user actions, and one "
|
|
"underlying problem can produce more than one report."
|
|
)
|
|
else:
|
|
sections.append(
|
|
"No broadly shared background problem appeared during this period."
|
|
)
|
|
sections.extend(
|
|
[
|
|
"",
|
|
"## How this report protects privacy",
|
|
"",
|
|
"The report is built from anonymous counts and percentages. "
|
|
"We do not publish or give Copilot user or session codes, file "
|
|
"fingerprints, locations, messages, workbook content, error messages, "
|
|
"or technical error details.",
|
|
"",
|
|
"Excel MCP never intentionally collects workbook contents, cell values, "
|
|
"formulas, prompts, messages, file names or paths, names, email addresses, "
|
|
"or account details. Read the full [privacy policy](/privacy/).",
|
|
"",
|
|
"You can inspect exactly how the report is built in "
|
|
"[`Update-UsageAnalytics.ps1`](https://github.com/sbroenne/"
|
|
"mcp-server-excel/blob/main/scripts/Update-UsageAnalytics.ps1) and "
|
|
"[`usage-analytics.yml`](https://github.com/sbroenne/mcp-server-excel/"
|
|
"blob/main/.github/workflows/usage-analytics.yml).",
|
|
]
|
|
)
|
|
return "\n".join(sections) + "\n"
|
|
|
|
|
|
# Generated file name (e.g. "features-data.md") -> repo-relative canonical
|
|
# source. Populated by _write during on_pre_build and read back when dating
|
|
# sitemap entries: a wrapper page's real "last modified" is driven by the
|
|
# canonical file it mirrors, not by the two-line wrapper.
|
|
MIRROR_SOURCES: dict[str, str] = {}
|
|
|
|
# Matches the snippet includes in the wrapper pages, e.g.
|
|
# --8<-- "_generated/features-data.md"
|
|
_GEN_INCLUDE = re.compile(r'--8<--\s*"_generated/([^"]+)"')
|
|
|
|
|
|
def _git_lastmod_index() -> dict[str, str]:
|
|
"""Map every tracked repo-relative path to its last commit date (W3C).
|
|
|
|
One ``git log`` walk over the whole history, newest first: the first time a
|
|
path appears is by definition its most recent change. This replaces the
|
|
previous behaviour of stripping ``<lastmod>`` altogether, which was done
|
|
because MkDocs stamps every URL with the *build* date - a false freshness
|
|
signal on every page in every deploy.
|
|
|
|
Returns an empty index (so ``<lastmod>`` is simply omitted) when git is
|
|
unavailable, which keeps ``mkdocs build`` working from a source tarball.
|
|
"""
|
|
try:
|
|
proc = subprocess.run(
|
|
["git", "log", "--format=%cI", "--name-only", "--no-renames"],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
check=True,
|
|
)
|
|
except (OSError, subprocess.CalledProcessError) as exc:
|
|
log.warning("git log failed (%s); sitemap will omit <lastmod>", exc)
|
|
return {}
|
|
|
|
index: dict[str, str] = {}
|
|
date = ""
|
|
for line in proc.stdout.splitlines():
|
|
if not line:
|
|
continue
|
|
# Commit-date lines are the only ones that can start with a 4-digit year
|
|
# followed by '-'; paths in this repo never do.
|
|
if len(line) >= 5 and line[:4].isdigit() and line[4] == "-":
|
|
date = line
|
|
elif date:
|
|
index.setdefault(line, date)
|
|
return index
|
|
|
|
|
|
def _git_is_shallow() -> bool:
|
|
"""True when the checkout has truncated history.
|
|
|
|
Worth reporting explicitly: a shallow clone still lists every tracked file,
|
|
just all under the tip commit's date, so the lastmod index looks perfectly
|
|
healthy while every date in it is wrong.
|
|
"""
|
|
try:
|
|
proc = subprocess.run(
|
|
["git", "rev-parse", "--is-shallow-repository"],
|
|
cwd=REPO_ROOT,
|
|
capture_output=True,
|
|
text=True,
|
|
check=True,
|
|
)
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return False
|
|
return proc.stdout.strip() == "true"
|
|
|
|
|
|
def _page_lastmod(files) -> dict[str, str]:
|
|
"""Map each page's ``src_uri`` to the newest git date that affects it.
|
|
|
|
For a wrapper page that is nothing but an ``--8<--`` include, that is the
|
|
date of the canonical source; the wrapper itself contributes its own date
|
|
too, so editing either one refreshes the entry.
|
|
"""
|
|
index = _git_lastmod_index()
|
|
if not index:
|
|
return {}
|
|
if _git_is_shallow():
|
|
# A shallow clone (actions/checkout's default fetch-depth: 1) still lists
|
|
# every tracked file - all under the tip commit's date. So the index
|
|
# looks healthy and only the dates are wrong; audit_site.py catches it by
|
|
# noticing that every page claims the same <lastmod>.
|
|
log.warning(
|
|
"shallow git clone: every sitemap <lastmod> will be the tip "
|
|
"commit's date - the workflow needs fetch-depth: 0"
|
|
)
|
|
|
|
lastmod: dict[str, str] = {}
|
|
for file in files.documentation_pages():
|
|
candidates = []
|
|
wrapper_rel = f"gh-pages/docs/{file.src_uri}"
|
|
if wrapper_rel in index:
|
|
candidates.append(index[wrapper_rel])
|
|
try:
|
|
text = Path(file.abs_src_path).read_text(encoding="utf-8")
|
|
except OSError:
|
|
text = ""
|
|
for name in _GEN_INCLUDE.findall(text):
|
|
source_rel = MIRROR_SOURCES.get(name)
|
|
if source_rel and source_rel in index:
|
|
candidates.append(index[source_rel])
|
|
if candidates:
|
|
# git's %cI keeps each committer's UTC offset, so the strings are
|
|
# not directly comparable as instants - parse before taking the max.
|
|
lastmod[file.src_uri] = max(candidates, key=datetime.fromisoformat)
|
|
return lastmod
|
|
|
|
|
|
def on_env(env, config, files, **kwargs): # noqa: D401 - MkDocs hook signature
|
|
"""Expose sitemap data to overrides/sitemap.xml."""
|
|
env.globals["page_lastmod"] = _page_lastmod(files)
|
|
env.globals["video"] = VIDEO
|
|
return env
|
|
|
|
|
|
DOCS_DIR = Path(__file__).resolve().parent / "docs"
|
|
# Mirrors the snippets `base_path` in mkdocs.yml, in the same order. Kept in
|
|
# sync so the llms.txt/mirror output resolves exactly what the site renders.
|
|
SNIPPET_BASE_PATHS = (DOCS_DIR, Path(__file__).resolve().parent)
|
|
|
|
|
|
def _resolve_snippets(text: str, depth: int = 0) -> str:
|
|
"""Expand ``--8<-- "path"`` includes.
|
|
|
|
``on_page_markdown`` fires before the snippets extension runs, so the raw
|
|
Markdown still contains include directives. Resolving them here is what makes
|
|
the Markdown mirrors and ``llms-full.txt`` complete rather than a list of
|
|
stub pages.
|
|
"""
|
|
if depth > 5:
|
|
return text
|
|
|
|
def repl(match: re.Match) -> str:
|
|
for base in SNIPPET_BASE_PATHS:
|
|
target = base / match.group(1)
|
|
if target.is_file():
|
|
return _resolve_snippets(target.read_text(encoding="utf-8"), depth + 1)
|
|
log.warning("snippet not found while building llms output: %s", match.group(1))
|
|
return ""
|
|
|
|
return _SNIPPET.sub(repl, text)
|
|
|
|
|
|
def _page_url(page) -> str:
|
|
return SITE_URL + page.url
|
|
|
|
|
|
# The resolved Navigation object, captured in on_nav. config["nav"] holds the raw
|
|
# YAML nav, which has no page objects to correlate with captured Markdown.
|
|
_NAV: list = []
|
|
|
|
|
|
def on_nav(nav, config, **kwargs): # noqa: D401 - MkDocs hook signature
|
|
_NAV.clear()
|
|
_NAV.extend(nav.items)
|
|
return nav
|
|
|
|
|
|
def on_page_markdown(markdown, page, config, **kwargs): # noqa: D401 - MkDocs hook
|
|
"""Capture each page's full Markdown for the LLM-facing outputs."""
|
|
body = _resolve_snippets(_FRONTMATTER.sub("", markdown)).strip()
|
|
_PAGE_MARKDOWN[page.file.src_uri] = {
|
|
"title": page.title or page.file.src_uri,
|
|
"url": _page_url(page),
|
|
"description": (page.meta or {}).get("description", "").strip(),
|
|
"markdown": body,
|
|
"dest": page.file.dest_uri,
|
|
}
|
|
|
|
faq = _faq_jsonld(body)
|
|
if faq:
|
|
page.meta["faq_jsonld"] = faq
|
|
return markdown
|
|
|
|
|
|
_FAQ_ADMONITION = re.compile(r'^\?{3}\+?\s+question\s+"([^"]+)"\s*$')
|
|
_FAQ_HEADING = re.compile(r"^###\s+(.+?)\s*$")
|
|
_FAQ_MIN_ENTITIES = 3
|
|
|
|
|
|
def _faq_jsonld(markdown: str) -> str:
|
|
"""Build FAQPage JSON-LD from a page's own question blocks.
|
|
|
|
Two source forms are recognised:
|
|
|
|
* ``### Some question?`` headings - preferred, because each answer keeps a
|
|
stable anchor that can be deep-linked from another page or straight from a
|
|
search result, and shows up in the page table of contents.
|
|
* ``??? question "..."`` collapsible admonitions, which have no anchor at
|
|
all, kept so a page written either way still works.
|
|
|
|
Either way the structured data is derived from the page body rather than
|
|
maintained separately, so the two cannot diverge.
|
|
"""
|
|
items: list[tuple[str, list[str]]] = []
|
|
current: list[str] | None = None
|
|
indented = False
|
|
|
|
for line in markdown.splitlines():
|
|
admonition = _FAQ_ADMONITION.match(line)
|
|
if admonition:
|
|
current = []
|
|
indented = True
|
|
items.append((admonition.group(1), current))
|
|
continue
|
|
|
|
heading = _FAQ_HEADING.match(line)
|
|
if heading:
|
|
text = heading.group(1).strip()
|
|
if text.endswith("?"):
|
|
current = []
|
|
indented = False
|
|
items.append((text, current))
|
|
else:
|
|
current = None
|
|
continue
|
|
|
|
if current is None:
|
|
continue
|
|
|
|
# A heading of any level ends a heading-sourced answer.
|
|
if not indented and line.startswith("#"):
|
|
current = None
|
|
continue
|
|
|
|
if not line.strip():
|
|
current.append("")
|
|
elif indented and not line.startswith((" ", "\t")):
|
|
current = None
|
|
else:
|
|
current.append(line.strip())
|
|
|
|
entities = []
|
|
for question, answer_lines in items:
|
|
# Fenced code blocks and table rows are useful on the page but pure noise
|
|
# inside a structured answer, so they are dropped here.
|
|
prose: list[str] = []
|
|
in_fence = False
|
|
for raw in answer_lines:
|
|
if raw.startswith("```"):
|
|
in_fence = not in_fence
|
|
continue
|
|
if in_fence or raw.startswith("|"):
|
|
continue
|
|
# Strip the list marker only where it starts a line, so a dash used
|
|
# mid-sentence survives into the structured answer.
|
|
prose.append(re.sub(r"^[-*+]\s+", "", raw))
|
|
|
|
answer = " ".join(x for x in prose if x).strip()
|
|
if not answer:
|
|
continue
|
|
# Strip inline Markdown so the structured answer is plain prose.
|
|
answer = _MD_LINK.sub(r"\1", answer)
|
|
answer = re.sub(r"[*_`]+", "", answer)
|
|
answer = re.sub(r"\s{2,}", " ", answer).strip()
|
|
entities.append(
|
|
{
|
|
"@type": "Question",
|
|
"name": question,
|
|
"acceptedAnswer": {"@type": "Answer", "text": answer},
|
|
}
|
|
)
|
|
|
|
# A page with one or two question-shaped headings is a guide that happens to
|
|
# ask a question, not an FAQ; emitting FAQPage there is a false signal.
|
|
if len(entities) < _FAQ_MIN_ENTITIES:
|
|
return ""
|
|
|
|
return json.dumps(
|
|
{"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": entities},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
|
|
def _nav_entries(items, out: list) -> None:
|
|
for item in items:
|
|
if getattr(item, "children", None):
|
|
_nav_entries(item.children, out)
|
|
elif getattr(item, "file", None) is not None:
|
|
out.append(item)
|
|
|
|
|
|
def _write_llm_outputs(config) -> None:
|
|
"""Emit /llms.txt, /llms-full.txt and one Markdown mirror per page.
|
|
|
|
``llms.txt`` follows the llmstxt.org convention: an H1, a blockquote summary,
|
|
then link sections. Both files and the mirrors are derived from the same
|
|
captured Markdown, so they cannot drift from the site.
|
|
"""
|
|
site_dir = Path(config["site_dir"])
|
|
|
|
# Markdown mirrors: /guides/refresh-power-query/index.md next to index.html.
|
|
mirrored = 0
|
|
for entry in _PAGE_MARKDOWN.values():
|
|
dest = site_dir / entry["dest"]
|
|
if dest.suffix != ".html":
|
|
continue
|
|
md_path = dest.with_suffix(".md")
|
|
md_path.parent.mkdir(parents=True, exist_ok=True)
|
|
md_path.write_text(
|
|
entry["markdown"] + "\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
mirrored += 1
|
|
|
|
# Section-aware index, ordered exactly like the site navigation.
|
|
lines = [
|
|
"# Excel MCP Server",
|
|
"",
|
|
"> Excel MCP Server (ExcelMcp) automates the real Microsoft Excel "
|
|
"application through its COM API, exposing 31 tools and 325 operations to AI assistants "
|
|
"over the Model Context Protocol and to scripts through "
|
|
"the `excelcli` command line. Unlike file-parser libraries it can refresh "
|
|
"Power Query, evaluate DAX against the Data Model, refresh PivotTables, "
|
|
"and run VBA, because Excel itself does the work. Windows-only; requires "
|
|
"Microsoft Excel 2016 or later.",
|
|
"",
|
|
"Every page below is also available as Markdown by appending `index.md` "
|
|
"to its URL. The complete corpus is at "
|
|
f"{SITE_URL}llms-full.txt.",
|
|
"",
|
|
]
|
|
|
|
def link_line(entry: dict) -> str:
|
|
url = entry["url"].rstrip("/")
|
|
url = f"{url}/index.md" if entry["dest"].endswith("index.html") else url
|
|
desc = f": {entry['description']}" if entry["description"] else ""
|
|
return f"- [{entry['title']}]({url}){desc}"
|
|
|
|
seen: set[str] = set()
|
|
for section in _NAV:
|
|
pages: list = []
|
|
_nav_entries([section], pages)
|
|
title = section.title if getattr(section, "title", None) else "Documentation"
|
|
rendered = []
|
|
for item in pages:
|
|
entry = _PAGE_MARKDOWN.get(item.file.src_uri)
|
|
if entry is None or item.file.src_uri in seen:
|
|
continue
|
|
seen.add(item.file.src_uri)
|
|
rendered.append(link_line(entry))
|
|
if rendered:
|
|
lines.append(f"## {title}")
|
|
lines.append("")
|
|
lines.extend(rendered)
|
|
lines.append("")
|
|
|
|
(site_dir / "llms.txt").write_text("\n".join(lines), encoding="utf-8", newline="\n")
|
|
|
|
# Full corpus, same order as llms.txt.
|
|
full = ["# Excel MCP Server - complete documentation", ""]
|
|
ordered: list = []
|
|
_nav_entries(_NAV, ordered)
|
|
emitted: set[str] = set()
|
|
for item in ordered:
|
|
entry = _PAGE_MARKDOWN.get(item.file.src_uri)
|
|
if entry is None or item.file.src_uri in emitted:
|
|
continue
|
|
emitted.add(item.file.src_uri)
|
|
full.append(f"# {entry['title']}")
|
|
full.append("")
|
|
full.append(f"Source: {entry['url']}")
|
|
full.append("")
|
|
full.append(entry["markdown"])
|
|
full.append("")
|
|
full.append("---")
|
|
full.append("")
|
|
(site_dir / "llms-full.txt").write_text(
|
|
"\n".join(full), encoding="utf-8", newline="\n"
|
|
)
|
|
|
|
log.info(
|
|
"wrote llms.txt, llms-full.txt and %d Markdown mirrors", mirrored
|
|
)
|
|
|
|
|
|
|
|
def on_pre_build(config, **kwargs): # noqa: D401 - MkDocs hook signature
|
|
_write(
|
|
"usage-analytics.md",
|
|
".github/usage-analytics.json",
|
|
_render_usage_analytics(),
|
|
)
|
|
|
|
_write(
|
|
"features.md",
|
|
"FEATURES.md",
|
|
_strip_header(_read("FEATURES.md"), end_on_blank=True),
|
|
)
|
|
|
|
# Canonical feature references -> focused website pages. The wrappers add
|
|
# presentation and SEO metadata but never duplicate operation details.
|
|
for output_name, source_rel in FEATURE_SOURCES.items():
|
|
_write(
|
|
output_name,
|
|
source_rel,
|
|
_add_stable_feature_anchors(
|
|
_strip_header(_read(source_rel), end_on_hr=True)
|
|
),
|
|
)
|
|
|
|
# Canonical task guides -> intent-focused website pages. The H1 lives in the
|
|
# wrapper, so drop it here and demote any remaining H1 to H2.
|
|
for output_name, source_rel in GUIDE_SOURCES.items():
|
|
_write(
|
|
output_name,
|
|
source_rel,
|
|
_strip_header(_read(source_rel), end_on_blank=True, demote_h1=True),
|
|
)
|
|
|
|
# CHANGELOG.md -> changelog (drop title + description line, demote H1)
|
|
_write(
|
|
"changelog.md",
|
|
"CHANGELOG.md",
|
|
_strip_header(
|
|
_read("CHANGELOG.md"),
|
|
drop_prefixes=("This changelog",),
|
|
end_on_blank=True,
|
|
demote_h1=True,
|
|
),
|
|
)
|
|
|
|
# docs/INSTALLATION.md -> installation (drop title + description line, demote H1)
|
|
_write(
|
|
"installation.md",
|
|
"docs/INSTALLATION.md",
|
|
_strip_header(
|
|
_read("docs/INSTALLATION.md"),
|
|
drop_prefixes=("Complete installation",),
|
|
end_on_blank=True,
|
|
demote_h1=True,
|
|
),
|
|
)
|
|
|
|
# docs/INSTALLATION-MCP-SERVER.md -> installation-mcp-server (drop title + description line, demote H1)
|
|
_write(
|
|
"installation-mcp-server.md",
|
|
"docs/INSTALLATION-MCP-SERVER.md",
|
|
_strip_header(
|
|
_read("docs/INSTALLATION-MCP-SERVER.md"),
|
|
end_on_blank=True,
|
|
demote_h1=True,
|
|
),
|
|
)
|
|
|
|
# docs/INSTALLATION-CLI.md -> installation-cli (drop title + description line, demote H1)
|
|
_write(
|
|
"installation-cli.md",
|
|
"docs/INSTALLATION-CLI.md",
|
|
_strip_header(
|
|
_read("docs/INSTALLATION-CLI.md"),
|
|
end_on_blank=True,
|
|
demote_h1=True,
|
|
),
|
|
)
|
|
|
|
# Canonical architecture and examples guides.
|
|
_write(
|
|
"architecture.md",
|
|
"docs/ARCHITECTURE.md",
|
|
_strip_header(_read("docs/ARCHITECTURE.md"), end_on_blank=True),
|
|
)
|
|
_write(
|
|
"use-cases.md",
|
|
"docs/USE-CASES.md",
|
|
_strip_header(_read("docs/USE-CASES.md"), end_on_blank=True),
|
|
)
|
|
|
|
# src/ExcelMcp.McpServer/README.md -> mcp-server (drop title, mcp-name, badges)
|
|
_write(
|
|
"mcp-server.md",
|
|
"src/ExcelMcp.McpServer/README.md",
|
|
_strip_header(
|
|
_read("src/ExcelMcp.McpServer/README.md"),
|
|
drop_prefixes=("<!-- mcp-name", "mcp-name:", "[!["),
|
|
end_on_blank=True,
|
|
demote_h1=True,
|
|
),
|
|
)
|
|
|
|
# src/ExcelMcp.CLI/README.md -> cli (drop title + badges, demote H1)
|
|
_write(
|
|
"cli.md",
|
|
"src/ExcelMcp.CLI/README.md",
|
|
_strip_header(
|
|
_read("src/ExcelMcp.CLI/README.md"),
|
|
drop_prefixes=("[![",),
|
|
end_on_blank=True,
|
|
demote_h1=True,
|
|
),
|
|
)
|
|
|
|
# skills/README.md -> skills (drop title, demote H1)
|
|
_write(
|
|
"skills.md",
|
|
"skills/README.md",
|
|
_strip_header(
|
|
_read("skills/README.md"),
|
|
end_on_blank=True,
|
|
demote_h1=True,
|
|
),
|
|
)
|
|
|
|
# skills/shared/*.md -> reference pages (drop the H1, wrapper owns the title)
|
|
for name, (output_name, _title) in SKILL_SOURCES.items():
|
|
_write(
|
|
output_name,
|
|
f"skills/shared/{name}",
|
|
_strip_header(
|
|
_read(f"skills/shared/{name}"), end_on_blank=True, demote_h1=True
|
|
),
|
|
)
|
|
|
|
# Verbatim copies (these keep their own H1 as the page title).
|
|
_write("contributing.md", "docs/CONTRIBUTING.md", _read("docs/CONTRIBUTING.md").strip() + "\n")
|
|
_write("security.md", "SECURITY.md", _read("SECURITY.md").strip() + "\n")
|
|
_write("privacy.md", "PRIVACY.md", _read("PRIVACY.md").strip() + "\n")
|
|
|
|
|
|
def _write_tools_json(config) -> None:
|
|
"""Emit /tools.json: every tool and operation as structured JSON.
|
|
|
|
Derived from the canonical ``docs/features/*.md`` references, so the machine
|
|
-readable catalogue is generated from the same source as the human pages and
|
|
cannot drift. Totals are asserted against the documented headline counts.
|
|
"""
|
|
category_titles = {
|
|
"docs/features/DATA-ANALYTICS.md": "Data & Analytics",
|
|
"docs/features/CELLS-WORKBOOKS.md": "Cells & Workbooks",
|
|
"docs/features/CHARTS-VISUALS.md": "Charts & Visualization",
|
|
"docs/features/AUTOMATION-ADVANCED.md": "Automation & Advanced",
|
|
}
|
|
site_page = {
|
|
"docs/features/DATA-ANALYTICS.md": "/features/data-analytics/",
|
|
"docs/features/CELLS-WORKBOOKS.md": "/features/cells-workbooks/",
|
|
"docs/features/CHARTS-VISUALS.md": "/features/charts-visuals/",
|
|
"docs/features/AUTOMATION-ADVANCED.md": "/features/automation-advanced/",
|
|
}
|
|
|
|
heading = re.compile(r"^## (?:\W+\s+)?(?P<name>.+?) \((?P<count>\d+) operations\)$")
|
|
operation = re.compile(r"^- \*\*(?P<name>[^:*]+):\*\*\s*(?P<desc>.+)$")
|
|
|
|
# Headline counts live in FEATURES.md and are enforced against code by
|
|
# scripts/check-doc-counts.ps1, so read them rather than restating them.
|
|
headline = re.search(
|
|
r"\*\*(?P<tools>\d+) specialized tools with (?P<ops>\d+) operations",
|
|
_read("FEATURES.md"),
|
|
)
|
|
if headline is None:
|
|
raise RuntimeError("could not read the headline tool/operation counts from FEATURES.md")
|
|
headline_tools = int(headline.group("tools"))
|
|
headline_ops = int(headline.group("ops"))
|
|
|
|
categories = []
|
|
total_ops = 0
|
|
|
|
for source_rel, title in category_titles.items():
|
|
groups: list[dict] = []
|
|
current: dict | None = None
|
|
for line in _read(source_rel).splitlines():
|
|
match = heading.match(line)
|
|
if match:
|
|
current = {
|
|
"name": match.group("name").strip(),
|
|
"operationCount": int(match.group("count")),
|
|
"operations": [],
|
|
}
|
|
groups.append(current)
|
|
continue
|
|
if current is None:
|
|
continue
|
|
op = operation.match(line)
|
|
if op:
|
|
current["operations"].append(
|
|
{
|
|
"name": op.group("name").strip(),
|
|
"description": op.group("desc").strip(),
|
|
}
|
|
)
|
|
|
|
total_ops += sum(g["operationCount"] for g in groups)
|
|
categories.append(
|
|
{
|
|
"name": title,
|
|
"url": SITE_URL.rstrip("/") + site_page[source_rel],
|
|
"operationCount": sum(g["operationCount"] for g in groups),
|
|
"featureGroups": groups,
|
|
}
|
|
)
|
|
|
|
if total_ops != headline_ops:
|
|
raise RuntimeError(
|
|
"tools.json operation total does not match the FEATURES.md headline: "
|
|
f"parsed {total_ops}, expected {headline_ops}. "
|
|
"Fix the feature reference headings or the headline."
|
|
)
|
|
|
|
payload = {
|
|
"name": "Excel MCP Server",
|
|
"url": SITE_URL,
|
|
"repository": "https://github.com/sbroenne/mcp-server-excel",
|
|
"description": (
|
|
"Automates the real Microsoft Excel application through its COM API, "
|
|
"exposing Excel to AI assistants over the Model Context Protocol and "
|
|
"to scripts through the excelcli command line."
|
|
),
|
|
"requirements": {
|
|
"operatingSystem": "Windows",
|
|
"application": "Microsoft Excel desktop 2016 or later",
|
|
},
|
|
"entryPoints": ["mcp-server", "cli"],
|
|
"toolCount": headline_tools,
|
|
"operationCount": total_ops,
|
|
"categories": categories,
|
|
}
|
|
|
|
(Path(config["site_dir"]) / "tools.json").write_text(
|
|
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
encoding="utf-8",
|
|
newline="\n",
|
|
)
|
|
log.info("wrote tools.json (%d tools, %d operations)", headline_tools, total_ops)
|
|
|
|
|
|
def on_post_build(config, **kwargs): # noqa: D401 - MkDocs hook signature
|
|
"""Write the LLM-facing outputs that MkDocs itself has no notion of.
|
|
|
|
The sitemap used to be rewritten here - stripping ``<lastmod>`` and splicing
|
|
in a ``<video:video>`` block with regexes, then re-gzipping by hand. Both
|
|
jobs now happen declaratively in ``overrides/sitemap.xml``, which also means
|
|
MkDocs writes ``sitemap.xml.gz`` from the same rendered output instead of the
|
|
two being kept in step manually.
|
|
"""
|
|
_write_llm_outputs(config)
|
|
_write_tools_json(config)
|
|
|
|
|
|
def on_post_page(output, page, config, **kwargs): # noqa: D401 - MkDocs hook signature
|
|
"""Give Material's search dialog an accessible name.
|
|
|
|
A role="dialog" with no name is a WCAG 4.1.2 failure. Unlike the logo and
|
|
progress-bar fixes - now declarative partials under ``overrides/`` - this one
|
|
stays a string patch on purpose: upstream's ``partials/search.html`` is ~45
|
|
lines of markup, icon lookups and feature flags, so copying it into
|
|
``overrides/`` to add one attribute would pin a large slice of Material
|
|
internals and silently miss every upstream change to the search UI.
|
|
|
|
Two variants because mkdocs-minify strips attribute quotes.
|
|
"""
|
|
output = output.replace(
|
|
'<div class="md-search" data-md-component="search" role="dialog">',
|
|
'<div class="md-search" data-md-component="search" role="dialog" '
|
|
'aria-label="Search documentation">',
|
|
)
|
|
output = output.replace(
|
|
"<div class=md-search data-md-component=search role=dialog>",
|
|
'<div class=md-search data-md-component=search role=dialog '
|
|
'aria-label="Search documentation">',
|
|
)
|
|
return output
|