Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit 97bdb73

Browse files
Zalenix Gardenersauravbhattacharya001
authored andcommitted
docs: add Open Graph/canonical metadata to all pages + verify in Pages CI
Backfills 74 documentation pages with the missing SEO trio: - <meta name=description> derived from <title> + first paragraph - <link rel=canonical> pointing at the live URL - <meta name=robots>, og:title/description/type/url/site_name, twitter:card/title/description Adds docs/_tools/inject_meta.py (idempotent injector) and docs/_tools/regen_sitemap.py (extracted from the docs/README inline snippet). Hardens .github/workflows/pages.yml with a verify step that fails the build if a page is missing from sitemap.xml or missing any of the four required head tags - so future pages can't ship without SEO metadata. Refreshes sitemap.xml lastmod to today (75 URLs).
1 parent 35386de commit 97bdb73

80 files changed

Lines changed: 1164 additions & 111 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/pages.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,50 @@ jobs:
4747
echo '<html><body><h1>Javadoc</h1><p>Javadoc generation pending. Build locally with <code>mvn javadoc:javadoc</code>.</p></body></html>' > docs/javadoc/index.html
4848
fi
4949
50+
- name: Verify docs metadata (sitemap + Open Graph)
51+
run: |
52+
python3 - <<'PY'
53+
import sys, os, re, xml.etree.ElementTree as ET
54+
ns = {'s': 'http://www.sitemaps.org/schemas/sitemap/0.9'}
55+
docs = 'docs'
56+
tree = ET.parse(os.path.join(docs, 'sitemap.xml'))
57+
locs = {u.text.rsplit('/', 1)[-1] or 'index.html'
58+
for u in tree.getroot().findall('s:url/s:loc', ns)}
59+
# The root URL (.../GraphVisual/) maps back to index.html.
60+
if '' in locs:
61+
locs.discard('')
62+
locs.add('index.html')
63+
html_files = {f for f in os.listdir(docs) if f.endswith('.html')}
64+
missing = sorted(html_files - locs - {'404.html'})
65+
extra = sorted(locs - html_files)
66+
fail = False
67+
if missing:
68+
print('::error::sitemap.xml is missing entries for:', ', '.join(missing))
69+
fail = True
70+
if extra:
71+
print('::error::sitemap.xml references non-existent pages:', ', '.join(extra))
72+
fail = True
73+
# Open Graph / canonical coverage check.
74+
required = ('og:title', 'og:description', 'rel="canonical"',
75+
'name="description"')
76+
bad = []
77+
for name in sorted(html_files - {'404.html'}):
78+
content = open(os.path.join(docs, name), encoding='utf-8').read()
79+
for tag in required:
80+
if tag not in content:
81+
bad.append(f'{name}: missing {tag}')
82+
if bad:
83+
for line in bad[:25]:
84+
print('::error::' + line)
85+
if len(bad) > 25:
86+
print(f'::error::...and {len(bad) - 25} more')
87+
fail = True
88+
if fail:
89+
sys.exit(1)
90+
print(f'OK: {len(html_files)} HTML pages, {len(locs)} sitemap entries, '
91+
f'Open Graph + canonical present on all pages.')
92+
PY
93+
5094
- name: Setup Pages
5195
uses: actions/configure-pages@v6
5296

docs/README.md

Lines changed: 32 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ docs/
1818
├── cookbook.html # End-to-end recipes
1919
├── algorithms/ # Per-analyzer pages (see ALGORITHMS.md for the list)
2020
├── styles.css # Shared stylesheet
21-
├── sitemap.xml # Auto-generated by tooling see "Regenerating" below
21+
├── sitemap.xml # Auto-generated by tooling - see "Regenerating" below
2222
├── robots.txt # Crawl policy (disallows /javadoc/, allows the rest)
2323
└── javadoc/ # Generated by `mvn javadoc:javadoc` in CI (not checked in)
2424
```
@@ -40,7 +40,7 @@ mvn javadoc:javadoc -Dmaven.javadoc.failOnError=false
4040
cp -r target/site/apidocs docs/javadoc
4141
```
4242

43-
`docs/javadoc/` is `.gitignore`d by convention it's regenerated on every
43+
`docs/javadoc/` is `.gitignore`d by convention - it's regenerated on every
4444
deploy by the Pages workflow.
4545

4646
## Regenerating `sitemap.xml`
@@ -51,53 +51,48 @@ priorities (landing page = 1.0, key references = 0.9, everything else = 0.7).
5151
To regenerate after adding or removing pages, run from the repo root:
5252

5353
```bash
54-
python - <<'PY'
55-
import os, datetime
56-
docs_dir = 'docs'
57-
base = 'https://sauravbhattacharya001.github.io/GraphVisual/'
58-
htmls = sorted(f for f in os.listdir(docs_dir) if f.endswith('.html'))
59-
today = datetime.date.today().isoformat()
60-
61-
def prio(name):
62-
if name == 'index.html':
63-
return ('1.0', 'weekly')
64-
if name in ('guide.html', 'api.html', 'architecture.html', 'cookbook.html'):
65-
return ('0.9', 'weekly')
66-
return ('0.7', 'monthly')
67-
68-
out = ['<?xml version="1.0" encoding="UTF-8"?>',
69-
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
70-
for f in htmls:
71-
p, cf = prio(f)
72-
loc = base + (f if f != 'index.html' else '')
73-
out += [' <url>',
74-
f' <loc>{loc}</loc>',
75-
f' <lastmod>{today}</lastmod>',
76-
f' <changefreq>{cf}</changefreq>',
77-
f' <priority>{p}</priority>',
78-
' </url>']
79-
out.append('</urlset>')
80-
open(os.path.join(docs_dir, 'sitemap.xml'), 'w', encoding='utf-8', newline='\n').write('\n'.join(out) + '\n')
81-
print(f'Wrote {len(htmls)} URLs')
82-
PY
54+
python docs/_tools/regen_sitemap.py
8355
```
8456

85-
Commit the updated `sitemap.xml` along with the new pages.
57+
Commit the updated `sitemap.xml` along with the new pages. The Pages workflow
58+
(`.github/workflows/pages.yml`) verifies on every deploy that every HTML page
59+
(except `404.html`) is referenced in `sitemap.xml` and ships with `og:title`,
60+
`og:description`, `rel="canonical"`, and `name="description"` tags - so a
61+
stale sitemap or a page missing SEO metadata fails the build instead of
62+
silently shipping.
63+
64+
## Refreshing Open Graph + canonical metadata
65+
66+
Every documentation page is required to carry `<meta name="description">`,
67+
`<link rel="canonical">`, `<meta name="robots">`, the `og:*` Open Graph set,
68+
and `twitter:card` / `twitter:title` / `twitter:description`. The injector at
69+
`docs/_tools/inject_meta.py` is idempotent - it only adds tags that are
70+
missing - so it is safe to re-run on the whole tree:
71+
72+
```bash
73+
python docs/_tools/inject_meta.py
74+
```
75+
76+
For a brand new page, write the `<title>` and a meaningful first `<h1>`/`<p>`,
77+
then let the injector backfill the rest.
8678

8779
## Adding a new documentation page
8880

8981
1. Create `docs/<topic>.html`. Follow the layout convention used by existing
90-
pages load `styles.css`, include the sidebar `<nav>` from `index.html`,
82+
pages - load `styles.css`, include the sidebar `<nav>` from `index.html`,
9183
and set a meaningful `<title>` and `<meta name="description">`.
9284
2. Add a sidebar entry in `index.html` (and any other landing pages that link
9385
to the section).
9486
3. Regenerate `sitemap.xml` (see above).
95-
4. Push to `master` the Pages workflow handles deployment.
87+
4. Push to `master` - the Pages workflow handles deployment.
9688

9789
## SEO checklist
9890

9991
- [x] `sitemap.xml` listing every page, referenced from `robots.txt`.
10092
- [x] `robots.txt` with explicit allow + Javadoc disallow.
101-
- [x] `<meta name="description">` on `index.html`.
102-
- [ ] Add `<meta name="description">` and Open Graph tags to remaining pages
103-
(tracked work — incremental).
93+
- [x] `<meta name="description">` on every page (enforced in CI).
94+
- [x] `<link rel="canonical">` and `<meta name="robots">` on every page.
95+
- [x] Open Graph (`og:title` / `og:description` / `og:type` / `og:url` /
96+
`og:site_name`) and Twitter Card tags on every page.
97+
- [ ] Add page-specific `og:image` for the highest-traffic pages
98+
(`index.html`, `guide.html`, `api.html`, `architecture.html`).

docs/_tools/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__pycache__/

docs/_tools/inject_meta.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env python3
2+
"""Inject canonical, robots, description, and Open Graph tags into every
3+
docs/*.html page that doesn't already have them.
4+
5+
Idempotent: re-running on the same tree adds nothing.
6+
7+
Strategy:
8+
- Skip 404.html (intentionally minimal).
9+
- Read <title> for og:title / fallback description.
10+
- Read first <h1> + first sentence of first <p> for a richer description
11+
when no <meta name="description"> is present.
12+
- Insert tags immediately after the existing <link rel="stylesheet"> line
13+
(or, failing that, before </head>).
14+
"""
15+
from __future__ import annotations
16+
17+
import re
18+
import sys
19+
from pathlib import Path
20+
21+
DOCS = Path(__file__).resolve().parent.parent
22+
SITE = "https://sauravbhattacharya001.github.io/GraphVisual/"
23+
SKIP = {"404.html"}
24+
25+
TITLE_RE = re.compile(r"<title>(.*?)</title>", re.IGNORECASE | re.DOTALL)
26+
H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.IGNORECASE | re.DOTALL)
27+
P_RE = re.compile(r"<p[^>]*>(.*?)</p>", re.IGNORECASE | re.DOTALL)
28+
TAG_STRIP_RE = re.compile(r"<[^>]+>")
29+
WS_RE = re.compile(r"\s+")
30+
STYLE_LINK_RE = re.compile(
31+
r'(\s*)<link\s+rel="stylesheet"\s+href="styles\.css"\s*/?>\s*\n',
32+
re.IGNORECASE,
33+
)
34+
HEAD_END_RE = re.compile(r"\s*</head>", re.IGNORECASE)
35+
36+
37+
def _clean(s: str) -> str:
38+
return WS_RE.sub(" ", TAG_STRIP_RE.sub("", s)).strip()
39+
40+
41+
def _truncate(text: str, limit: int = 160) -> str:
42+
text = text.strip()
43+
if len(text) <= limit:
44+
return text
45+
cut = text[: limit - 1]
46+
sp = cut.rfind(" ")
47+
if sp > limit * 0.6:
48+
cut = cut[:sp]
49+
return cut.rstrip(",.;:- ") + "\u2026"
50+
51+
52+
def _attr_escape(value: str) -> str:
53+
return (
54+
value.replace("&", "&amp;")
55+
.replace('"', "&quot;")
56+
.replace("<", "&lt;")
57+
.replace(">", "&gt;")
58+
)
59+
60+
61+
def derive_meta(html: str, filename: str) -> tuple[str, str, str]:
62+
"""Return (page_title, og_title, description)."""
63+
title_match = TITLE_RE.search(html)
64+
raw_title = _clean(title_match.group(1)) if title_match else filename
65+
# og:title strips the " - GraphVisual" / " - GraphVisual Docs" suffix.
66+
og_title = re.sub(r"\s*[\u2013\u2014\-]\s*GraphVisual.*$", "", raw_title).strip() or raw_title
67+
68+
desc_match = re.search(
69+
r'<meta\s+name="description"\s+content="([^"]+)"', html, re.IGNORECASE
70+
)
71+
if desc_match:
72+
description = desc_match.group(1).strip()
73+
else:
74+
h1 = H1_RE.search(html)
75+
h1_text = _clean(h1.group(1)) if h1 else og_title
76+
p_text = ""
77+
for p in P_RE.finditer(html):
78+
candidate = _clean(p.group(1))
79+
if len(candidate) >= 40:
80+
p_text = candidate
81+
break
82+
if p_text:
83+
description = f"{h1_text}: {p_text}" if h1_text and h1_text not in p_text else p_text
84+
else:
85+
description = f"{h1_text} - GraphVisual documentation."
86+
# Strip leading emoji / symbols so descriptions read cleanly.
87+
description = re.sub(r"^[^\w]+", "", description).strip()
88+
return raw_title, og_title, _truncate(description)
89+
90+
91+
def inject(html: str, filename: str) -> tuple[str, bool]:
92+
raw_title, og_title, description = derive_meta(html, filename)
93+
canonical_url = SITE + filename
94+
95+
new_tags: list[str] = []
96+
if 'name="description"' not in html.lower():
97+
new_tags.append(
98+
f' <meta name="description" content="{_attr_escape(description)}">'
99+
)
100+
if 'rel="canonical"' not in html.lower():
101+
new_tags.append(f' <link rel="canonical" href="{canonical_url}">')
102+
if 'name="robots"' not in html.lower():
103+
new_tags.append(' <meta name="robots" content="index, follow">')
104+
if "og:title" not in html.lower():
105+
new_tags.append(
106+
f' <meta property="og:title" content="{_attr_escape(og_title)}">'
107+
)
108+
if "og:description" not in html.lower():
109+
new_tags.append(
110+
f' <meta property="og:description" content="{_attr_escape(description)}">'
111+
)
112+
if "og:type" not in html.lower():
113+
new_tags.append(' <meta property="og:type" content="website">')
114+
if "og:url" not in html.lower():
115+
new_tags.append(f' <meta property="og:url" content="{canonical_url}">')
116+
if "og:site_name" not in html.lower():
117+
new_tags.append(' <meta property="og:site_name" content="GraphVisual">')
118+
if "twitter:card" not in html.lower():
119+
new_tags.append(
120+
' <meta name="twitter:card" content="summary_large_image">'
121+
)
122+
if "twitter:title" not in html.lower():
123+
new_tags.append(
124+
f' <meta name="twitter:title" content="{_attr_escape(og_title)}">'
125+
)
126+
if "twitter:description" not in html.lower():
127+
new_tags.append(
128+
f' <meta name="twitter:description" content="{_attr_escape(description)}">'
129+
)
130+
131+
if not new_tags:
132+
return html, False
133+
134+
block = "\n".join(new_tags) + "\n"
135+
136+
style_match = STYLE_LINK_RE.search(html)
137+
if style_match:
138+
insert_at = style_match.start()
139+
return html[:insert_at] + "\n" + block + html[insert_at:].lstrip("\n"), True
140+
141+
head_end_match = HEAD_END_RE.search(html)
142+
if head_end_match:
143+
insert_at = head_end_match.start()
144+
return html[:insert_at] + "\n" + block + html[insert_at:].lstrip("\n"), True
145+
146+
# No <head> closing tag found; bail out without modification.
147+
return html, False
148+
149+
150+
def main() -> int:
151+
docs = DOCS
152+
if not docs.exists():
153+
print(f"docs dir not found at {docs}", file=sys.stderr)
154+
return 1
155+
156+
updated: list[str] = []
157+
for path in sorted(docs.glob("*.html")):
158+
if path.name in SKIP:
159+
continue
160+
original = path.read_text(encoding="utf-8")
161+
new_html, changed = inject(original, path.name)
162+
if changed:
163+
path.write_text(new_html, encoding="utf-8", newline="\n")
164+
updated.append(path.name)
165+
166+
print(f"Updated {len(updated)} page(s).")
167+
for name in updated:
168+
print(f" - {name}")
169+
return 0
170+
171+
172+
if __name__ == "__main__":
173+
raise SystemExit(main())

docs/_tools/regen_sitemap.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import os, datetime
2+
docs_dir = 'docs'
3+
base = 'https://sauravbhattacharya001.github.io/GraphVisual/'
4+
htmls = sorted(f for f in os.listdir(docs_dir) if f.endswith('.html'))
5+
today = datetime.date.today().isoformat()
6+
7+
def prio(name):
8+
if name == 'index.html':
9+
return ('1.0', 'weekly')
10+
if name in ('guide.html', 'api.html', 'architecture.html', 'cookbook.html'):
11+
return ('0.9', 'weekly')
12+
return ('0.7', 'monthly')
13+
14+
out = ['<?xml version="1.0" encoding="UTF-8"?>',
15+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">']
16+
for f in htmls:
17+
p, cf = prio(f)
18+
loc = base + (f if f != 'index.html' else '')
19+
out += [' <url>',
20+
f' <loc>{loc}</loc>',
21+
f' <lastmod>{today}</lastmod>',
22+
f' <changefreq>{cf}</changefreq>',
23+
f' <priority>{p}</priority>',
24+
' </url>']
25+
out.append('</urlset>')
26+
open(os.path.join(docs_dir, 'sitemap.xml'), 'w', encoding='utf-8', newline='\n').write('\n'.join(out) + '\n')
27+
print(f'Wrote {len(htmls)} URLs')

docs/anomaly.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>Graph Anomaly Detector — GraphVisual</title>
7+
<meta name="description" content="Graph Anomaly Detector: Proactively identify suspicious nodes and edges using statistical outlier detection across multiple graph metrics. Anomalous nodes are…">
8+
<link rel="canonical" href="https://sauravbhattacharya001.github.io/GraphVisual/anomaly.html">
9+
<meta name="robots" content="index, follow">
10+
<meta property="og:title" content="Graph Anomaly Detector">
11+
<meta property="og:description" content="Graph Anomaly Detector: Proactively identify suspicious nodes and edges using statistical outlier detection across multiple graph metrics. Anomalous nodes are…">
12+
<meta property="og:type" content="website">
13+
<meta property="og:url" content="https://sauravbhattacharya001.github.io/GraphVisual/anomaly.html">
14+
<meta property="og:site_name" content="GraphVisual">
15+
<meta name="twitter:card" content="summary_large_image">
16+
<meta name="twitter:title" content="Graph Anomaly Detector">
17+
<meta name="twitter:description" content="Graph Anomaly Detector: Proactively identify suspicious nodes and edges using statistical outlier detection across multiple graph metrics. Anomalous nodes are…">
718
<link rel="stylesheet" href="styles.css">
819
<style>
920
.anomaly-layout { display: grid; grid-template-columns: 300px 1fr; gap: 24px; margin-top: 24px; }

docs/api.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
<meta charset="UTF-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1.0">
66
<title>API Reference — GraphVisual Docs</title>
7+
<meta name="description" content="API Reference Public classes and methods in GraphVisual: Edge model representing a connection between two vertices in the social network graph. Each edge has…">
8+
<link rel="canonical" href="https://sauravbhattacharya001.github.io/GraphVisual/api.html">
9+
<meta name="robots" content="index, follow">
10+
<meta property="og:title" content="API Reference">
11+
<meta property="og:description" content="API Reference Public classes and methods in GraphVisual: Edge model representing a connection between two vertices in the social network graph. Each edge has…">
12+
<meta property="og:type" content="website">
13+
<meta property="og:url" content="https://sauravbhattacharya001.github.io/GraphVisual/api.html">
14+
<meta property="og:site_name" content="GraphVisual">
15+
<meta name="twitter:card" content="summary_large_image">
16+
<meta name="twitter:title" content="API Reference">
17+
<meta name="twitter:description" content="API Reference Public classes and methods in GraphVisual: Edge model representing a connection between two vertices in the social network graph. Each edge has…">
718
<link rel="stylesheet" href="styles.css">
819
</head>
920
<body>

0 commit comments

Comments
 (0)