Interactive decision tree that matches a Microsoft 365 identity profile — privileged admin, information worker, frontline (F1/F3), education (A1/A3/A5), government (GCC / GCC High / DoD / IL6), nonprofit, SMB, or External ID guest — to the right license tier.
Live site: https://billmcilhargey.github.io/m365-profiles/
⚠️ Not official Microsoft guidance. This is an independent community helper — not a Microsoft product, not endorsed by Microsoft. Always verify SKUs and entitlements with your Microsoft account team. The Microsoft Product Terms are the source of truth.
- Static multi-page Astro site — no backend, no analytics, no cookies.
- 82 decision-tree nodes (28 questions, 7 choice screens, 4 info screens, 43 results), every edge verified reachable on every push.
- Card-based UI with progress bar, source citations, full keyboard support, and one-click PDF handout (jsPDF, lazy-loaded only on click).
- Light / dark theme that respects
prefers-color-schemeand persists locally. - Reference catalog at
/referencerenders every result node as a static, indexable page for users without JavaScript. - Dynamic
robots.txt,sitemap-index.xml, andversion.json— forks and custom domains work without editing static files. - Released as a CalVer GitHub Release on every push to
main.
git clone https://github.com/billmcilhargey/m365-profiles.git
cd m365-profiles
npm ci
npm run validate-tree # verify the decision-tree wiring
npm run dev # http://localhost:4321/m365-profiles
npm run build # production build → ./dist
npm run preview # serve ./dist locallyRequirements: Node 20+ and npm 10+.
Every user-editable knob lives in src/lib/config.js — a
single CONFIG object read by the Astro config, the layout, the web manifest,
the CSP meta tag, and the CI workflow. Edit one file and the change propagates
everywhere. Derived helpers in src/lib/site.ts, security.ts, and brand.ts
read from CONFIG and should not be edited directly.
| Key | Default | What it controls |
|---|---|---|
origin |
https://billmcilhargey.github.io |
Site origin (no trailing slash). Astro.site, JSON-LD @id, robots.txt sitemap, canonical URLs. Override with PUBLIC_SITE_URL. |
base |
/m365-profiles |
Site base path. Astro.base and every internal-link prefix. Override with PUBLIC_SITE_BASE. |
name |
M365 Profiles |
Brand name in navbar, footer, <title>, manifest, JSON-LD. |
tagline |
Microsoft 365 Licensing Decision Tree |
Home <title> suffix and JSON-LD description. |
owner |
Dr. Bill Mcilhargey |
Footer copyright, <meta author>, JSON-LD Person. |
repo |
billmcilhargey/m365-profiles |
GitHub owner/repo slug — drives GITHUB_URL and source deep-links. |
defaultBranch |
main |
Branch used in blob/<branch>/… deep-links. |
Mirror these in src/styles/tokens.css (--ms-blue, --ms-blue-darkest, --ms-square-1..4) — CSS can't import JS at runtime.
| Key | Default | What it controls |
|---|---|---|
colors.blue |
#0078d4 |
Primary → <meta theme-color>, PDF headings. |
colors.navy |
#003e72 |
Secondary → PDF headings, hover accents. |
colors.squares |
["#f25022","#7fba00","#00a4ef","#ffb900"] |
Four-square palette → nav logo + auto-derived favicon. |
| Key | Default | What it controls |
|---|---|---|
defaultDescription |
(see file) | Fallback <meta description>. |
language / direction |
en / ltr |
<html lang> / <html dir> and JSON-LD inLanguage. |
titleSeparator |
— |
pageTitle("X") → "X — Brand". |
manifest.description |
(see file) | Install-prompt description. |
manifest.display |
minimal-ui |
PWA display mode. |
manifest.orientation |
any |
PWA orientation lock. |
manifest.backgroundColor |
#ffffff |
PWA splash background. |
storage.theme |
m365-theme |
localStorage key for color theme. |
storage.knownVersion |
m365-known-version |
localStorage key for cache-bust detection. |
storage.assessmentStatePrefix |
m365-assessment-state |
sessionStorage prefix; suffixed with APP_VERSION so deploys invalidate stale sessions. |
Delivered as <meta> tags from src/layouts/Base.astro — GitHub Pages can't set real HTTP headers. Loosen at your own risk.
| Key | Default | What it controls |
|---|---|---|
security.csp |
strict same-origin (11 directives) | Joined with a ; + space separator into <meta http-equiv="Content-Security-Policy">. |
security.referrerPolicy |
strict-origin-when-cross-origin |
<meta name="referrer">. |
security.permissionsPolicy |
denies camera / mic / geolocation / FLoC | <meta http-equiv="Permissions-Policy">. |
The full "essential meta tags for social media" set (css-tricks.com)
is emitted from Base.astro on every page —
og:title, og:description, og:type, og:url, og:site_name, og:locale,
og:image + width + height + alt, twitter:card, twitter:title,
twitter:description, twitter:image, twitter:image:alt, and optional
twitter:site / twitter:creator / fb:app_id. Per-page overrides are
passed via the <Base> props (ogTitle, ogDescription, ogType,
ogImageSlug, ogImageAlt).
OG images themselves are also dynamic — there are no binary card PNGs in
public/. The card SVG is generated at build time by
src/lib/og.ts from CONFIG.colors + a per-page text spec,
and served by two routes:
/og-image.svg— default card, also referenced by the web manifest./og/<slug>.svg— one per entry inCONFIG.social.ogImages, selected per page via<Base ogImageSlug="home" />. Forks get recoloured share cards for free.
| Key | Default | What it controls |
|---|---|---|
social.region |
US |
Combined with language → og:locale (e.g. en_US). |
social.ogType |
website |
Default og:type. Pages can override per call. |
social.ogImageWidth / Height |
1200 / 630 |
Dimensions baked into the SVG card and emitted as og:image:width/height. |
social.ogImageAlt |
(see file) | Default og:image:alt / twitter:image:alt. |
social.twitterSite |
"" |
If set (e.g. @handle), emits <meta name="twitter:site">. |
social.twitterCreator |
"" |
If set, emits <meta name="twitter:creator">. |
social.fbAppId |
"" |
If set, emits <meta property="fb:app_id"> for Facebook Domain Insights. |
social.profiles |
[] |
Owner profile URLs → <link rel="me"> (Mastodon / IndieWeb) + JSON-LD Person.sameAs (Knowledge Graph). |
social.ogImages |
6 entries | { eyebrow, headline, subline } per page → one SVG card each at /og/<key>.svg. |
| Key | Default | What it controls |
|---|---|---|
seo.robots |
index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1 |
<meta name="robots"> + <meta name="googlebot">. Per-page override via <Base robots="noindex, follow" /> (already set on /404). |
seo.emitHreflang |
true |
Emit self-referential <link rel="alternate" hreflang> + x-default. |
seo.emitBreadcrumbs |
true |
Auto-derive a JSON-LD BreadcrumbList from the URL path on non-home pages. Pages can override with a breadcrumbs prop. |
seo.appleStatusBarStyle |
default |
iOS PWA status-bar style: default | black | black-translucent. |
seo.themeColorDark |
#003e72 |
Dark-mode <meta name="theme-color">. Light variant uses colors.blue. |
Other always-on additions: format-detection (disables iOS auto-linking of phone numbers / addresses / emails), application-name, apple-mobile-web-app-title|capable|status-bar-style, mobile-web-app-capable, msapplication-TileColor, apple-touch-icon (auto-derived from CONFIG.colors.squares), and mask-icon for Safari pinned tabs. JSON-LD now includes datePublished / dateModified and primaryImageOfPage. A dynamic /llms.txt describes the site to LLM crawlers per the llmstxt.org convention.
All icons are SVG, generated at build time by src/lib/icons.ts from CONFIG.colors.squares — there are no PNG / ICO binaries to keep in sync. Three purposes (W3C manifest spec) are rendered:
| Purpose | Used for | How it renders |
|---|---|---|
any |
Browser tab, app list, home screen | Full four-color mark, edge-to-edge. |
maskable |
Android adaptive icons | Same mark at 60% scale inside a brand-blue safe-zone bleed. |
monochrome |
Notification badges, watch faces, Safari pinned tabs | Single-color silhouette (defaults to brand blue). |
Endpoints (all built statically — no runtime calls):
/favicon.svg— primary favicon./icons/<slug>.svg— one per entry inICON_SET:favicon-32,favicon-192,favicon-512,apple-touch-180,maskable-192,maskable-512,monochrome-512.
The web manifest advertises all five PWA sizes (any × 2 + maskable × 2 + monochrome × 1) plus two app shortcuts (Start assessment, Reference catalog) so installed PWAs get a long-press menu. Base.astro emits an inline data-URI favicon (zero round-trips) plus file-URL rel="icon", apple-touch-icon, and mask-icon links for crawlers, iOS, and Safari.
CONFIG.externalSources— array of{ href, label }rendered in the footer.- The deploy workflow resolves URL/base from Actions variables
PUBLIC_SITE_URL/PUBLIC_SITE_BASEfirst, then falls back toCONFIG.origin/CONFIG.base. Forks can target their own Pages URL without editingconfig.js.
All decision logic lives in src/data/tree.js. Each node
is one of: a question (yes/no), a choice (n-way picker), an info screen,
or a terminal result. Workflow:
- Edit nodes in
src/data/tree.js. - Re-wire
yes/no/targetedges to point at your node ids. npm run validate-tree— confirms every edge resolves and every node is reachable from the start node.npm run buildto confirm the site still compiles.- Open a PR —
lint.ymlre-runs validation, link-check, markdownlint, and dependency review.
CI sets PUBLIC_APP_VERSION (CalVer vYYYY.MM.DD-N) and PUBLIC_BUILD_DATE
before astro build, so every page, the footer, the PDF, and /version.json
all bake the same string. On load the client fetches /version.json and, if a
new build is detected, clears sessionStorage and reloads — users see the new
release as soon as they revisit the tab.
| Workflow | Trigger | What it does |
|---|---|---|
deploy.yml |
push to main, manual |
Validate tree → build → publish to GitHub Pages → tag CalVer release. Add [skip release] to skip tagging. |
lint.yml |
push + PR to main |
Build + tree validation, markdownlint, link check, dependency review (PRs). |
Enable Pages on a fork: push to main, then Settings → Pages → Source: GitHub Actions.
Custom domain: add public/CNAME with the bare hostname, configure DNS + HTTPS under Settings → Pages, and either set the PUBLIC_SITE_URL Actions variable or edit CONFIG.origin.
astro.config.mjs Astro config (site, base, MDX, sitemap)
scripts/
validate-tree.js Tree wiring + reachability check
src/
config.js (in lib/) Single source of truth for editable knobs
data/tree.js 82-node decision tree
lib/ Derived constants + DOM/path helpers (do not edit)
client/ Assessment renderer + lazy PDF builder
layouts/Base.astro HTML shell, meta tags, CSP, cache-bust bootstrap
components/ Nav, Footer, Disclaimer
pages/ index, assessment, profiles, reference, about, 404,
robots.txt, sitemap, manifest, version.json,
og-image.svg, og/[slug].svg, llms.txt,
favicon.svg, icons/[slug].svg
styles/ Tokens, base, components, print
content/ MDX explainers keyed to result ids
public/ Optional static assets (CNAME for custom domains)
.github/ Workflows, dependabot, CODEOWNERS, issue templates
| Key | Action |
|---|---|
1–9, A–Z |
Pick a numbered / lettered choice |
Y / N (or 1 / 2) |
Yes / No on question screens |
← or Backspace |
Go back one step |
R |
Restart |
Tab / Enter / Space |
Standard focus + activation |
Static site. No backend, no telemetry, no cookies. Defense-in-depth <meta>
headers (CSP, Referrer-Policy, Permissions-Policy). Report vulnerabilities
privately — see SECURITY.md.
Microsoft, Microsoft 365, Azure, Entra, Defender, Purview, Intune, Teams, Copilot, and related product names are trademarks of Microsoft Corporation. This site uses those names nominatively to identify the products discussed. The four colored accent squares used as a favicon and navbar mark are not the Microsoft corporate logo. This project is not affiliated with, sponsored by, or endorsed by Microsoft Corporation.
- UI patterns inspired by Microsoft Zero Trust Assessment.
- License cross-references on top of m365maps.com by Aaron Dinnage.
- Built with Astro 5 and jsPDF.
MIT © Dr. Bill Mcilhargey