Skip to content

Commit db017cd

Browse files
committed
feat(seo): add SEO features including sitemap and robots.txt support
- Introduced server-rendered SEO pages for courses and programs. - Added sitemap.xml and robots.txt endpoints for better search engine indexing. - Updated frontend to support SEO metadata and structured data. - Created a checklist for deploying SEO changes. - Enhanced course and program pages with JSON-LD structured data for improved visibility.
1 parent eb561a1 commit db017cd

19 files changed

Lines changed: 1373 additions & 6 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Server Configuration
22
PORT=8080
33

4+
# Public site URL (canonical links, sitemap, Open Graph). Set to your production domain.
5+
SITE_BASE_URL=https://your-domain.example.com
6+
47
# Database Configuration (Supabase PostgreSQL)
58
DATABASE_URL=postgres://postgres:[YOUR_PASSWORD]@db.xkzadsnvjdrspymgcoaq.supabase.co:5432/postgres
69

docs/seo-deploy-checklist.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# SEO deploy checklist
2+
3+
After merging SEO changes to `main` and deploying production:
4+
5+
1. Set `SITE_BASE_URL` in production env to your live domain (no trailing slash), e.g. `https://infolinks.example.com`.
6+
2. Redeploy the Go server and rebuilt frontend if applicable.
7+
3. Verify pages:
8+
- `/robots.txt` references your sitemap URL
9+
- `/sitemap.xml` lists `/`, `/courses`, `/course/{code}`, `/program/{slug}`
10+
- `/course/{known-code}` returns HTML with links and FAQ
11+
- `/?highlight={code}` opens home with search and scroll
12+
4. [Google Search Console](https://search.google.com/search-console): verify property, submit `sitemap.xml`.
13+
5. Request indexing for your top ~20 course codes (`/course/nfa035`, etc.).
14+
6. Share course URLs in [@Info_Links9](https://t.me/Info_Links9) and year-group chats (not only `/`).
15+
7. Monthly: review Search Console queries; tune page titles for pages ranking positions 8–15.
16+
17+
While finishing `go-backend-migration`, merge `main` into that branch weekly to keep SEO routes in sync.

frontend/index.html

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
<!doctype html>
2-
<html lang="en">
2+
<html lang="fr">
33

44
<head>
55
<meta charset="UTF-8" />
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
77
<link rel="stylesheet" href="/styles/app.css">
8-
<title>Info Links — CNAM Lebanon CS Resources</title>
8+
<title>Info Links — CNAM Liban matériaux cours (ISAE CNAM, génie informatique)</title>
99
<meta name="description"
10-
content="A curated hub of study resources for Le CNAM Lebanon Computer Science students. Find links to courses, sessions, CISCO, DELF B2, and more." />
10+
content="Hub étudiant CNAM Liban et ISAE CNAM : matériaux, TD, cours, examens, sessions, vidéos. Licence info, master AISL, IRSM, génie informatique — 50+ cours par code (NFA035, NSY107…)." />
11+
<meta property="og:title" content="Info Links — CNAM Liban matériaux cours" />
12+
<meta property="og:description"
13+
content="Ressources CNAM Liban : TD, cours, examens, sessions. Génie informatique, licence info, master. Liens Drive, Telegram, Classroom." />
14+
<meta property="og:type" content="website" />
15+
<meta property="og:url" content="/" />
1116
<meta name="theme-color" content="#6c63ff" />
17+
<script type="application/ld+json">
18+
{
19+
"@context": "https://schema.org",
20+
"@type": "WebSite",
21+
"name": "Info Links",
22+
"description": "Matériaux et liens cours CNAM Liban — TD, cours, examens, sessions, vidéos",
23+
"potentialAction": {
24+
"@type": "SearchAction",
25+
"target": "/?highlight={search_term_string}",
26+
"query-input": "required name=search_term_string"
27+
}
28+
}
29+
</script>
1230
<link rel="apple-touch-icon" sizes="180x180" href="assets/apple-touch-icon.png" />
1331
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32x32.png" />
1432
<link rel="icon" type="image/png" sizes="16x16" href="assets/favicon-16x16.png" />

frontend/js/export.js

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,64 @@ document.getElementById("modal").addEventListener("click", (e) => {
6767
if (e.target === document.getElementById("modal")) closeModal();
6868
});
6969

70+
function applyHighlightFromURL() {
71+
const params = new URLSearchParams(window.location.search);
72+
const raw = (params.get("highlight") || params.get("q") || "").trim();
73+
if (!raw) return;
74+
75+
showView("home");
76+
AppState.currentProg = "all";
77+
document.querySelector(".filter-row").style.display = "none";
78+
const extra = document.getElementById("extraSection");
79+
if (extra) extra.style.display = "";
80+
renderProgTabs();
81+
renderYearFilters();
82+
renderSemFilters();
83+
84+
const search = document.getElementById("searchInput");
85+
if (search) search.value = raw;
86+
onSearch();
87+
88+
requestAnimationFrame(() => {
89+
const q = raw.toLowerCase();
90+
let targetId = null;
91+
AppState.courseById.forEach((c) => {
92+
if (targetId) return;
93+
if (
94+
c.code.toLowerCase() === q ||
95+
c.code.toLowerCase().includes(q) ||
96+
c.name.toLowerCase().includes(q)
97+
) {
98+
targetId = c.id;
99+
}
100+
});
101+
if (targetId) {
102+
document
103+
.getElementById(`course-card-${targetId}`)
104+
?.scrollIntoView({ behavior: "smooth", block: "center" });
105+
}
106+
});
107+
}
108+
70109
async function initApp() {
71110
trackVisit();
72111
await loadAll();
73-
112+
113+
const highlight =
114+
new URLSearchParams(window.location.search).get("highlight") ||
115+
new URLSearchParams(window.location.search).get("q");
116+
if (highlight && highlight.trim()) {
117+
applyHighlightFromURL();
118+
return;
119+
}
120+
74121
// Restore view from URL path (enables deep-linking with Clean URLs)
75122
const v = _getPathView();
76123
if (v !== "home") {
77124
showView(v);
78125
}
79126
}
127+
window.applyHighlightFromURL = applyHighlightFromURL;
80128
window.initApp = initApp;
81129
window.showToast = showToast;
82130
window.exportData = exportData;

frontend/js/ui.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,10 +165,10 @@ function _buildCourseCard(c) {
165165
return `
166166
<div class="course-card" id="course-card-${c.id}">
167167
<div class="course-header">
168-
<div class="course-name">${esc(c.name)}</div>
168+
<h2 class="course-name">${esc(c.name)}</h2>
169169
<div style="display:flex;align-items:center;gap:6px;">
170170
${c.is_optional ? '<span class="optional-tag">OPTIONAL</span>' : ""}
171-
<div class="course-code">${esc(c.code)}</div>
171+
<h3 class="course-code">${esc(c.code)}</h3>
172172
<button class="fav-btn ${isFav ? "active" : ""}"
173173
title="${isFav ? "Remove from My Courses" : "Add to My Courses"}"
174174
onclick="handleFavoriteToggle(${c.id})"

frontend/robots.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
User-agent: *
2+
Allow: /
3+
Disallow: /admin
4+
Disallow: /admin-gate
5+
6+
Sitemap: /sitemap.xml

frontend/vite.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ export default defineConfig({
1010
server: {
1111
proxy: {
1212
'/api': 'http://localhost:8080',
13+
'/robots.txt': 'http://localhost:8080',
14+
'/sitemap.xml': 'http://localhost:8080',
15+
'/courses': 'http://localhost:8080',
16+
'/course': 'http://localhost:8080',
17+
'/program': 'http://localhost:8080',
1318
},
1419
},
1520
plugins: [

internal/api/router.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"path/filepath"
77
"strings"
88

9+
"infolinks-backend/internal/seo"
10+
911
"github.com/rs/cors"
1012
)
1113

@@ -50,6 +52,15 @@ func NewRouter() http.Handler {
5052
mux.HandleFunc("GET /api/admin/page_views", RequireAdmin(HandleAdminGetPageViews))
5153
mux.HandleFunc("GET /api/admin/link_clicks", RequireAdmin(HandleAdminGetLinkClicks))
5254

55+
// SEO — server-rendered pages (before SPA catch-all)
56+
seoH := seo.NewHandler()
57+
// Patterns without "GET " match all methods (GET, HEAD) for crawlers.
58+
mux.HandleFunc("/course/{code}", seoH.HandleCourse)
59+
mux.HandleFunc("/program/{slug}", seoH.HandleProgram)
60+
mux.HandleFunc("/courses", seoH.HandleCoursesIndex)
61+
mux.HandleFunc("/sitemap.xml", seoH.HandleSitemap)
62+
mux.HandleFunc("/robots.txt", seoH.HandleRobots)
63+
5364
// 3. Static Files & SPA Routing
5465
staticDir := "frontend/dist"
5566
if _, err := os.Stat(staticDir); err != nil {
@@ -58,6 +69,15 @@ func NewRouter() http.Handler {
5869

5970
fs := http.FileServer(http.Dir(staticDir))
6071
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
72+
// SEO paths must not fall through to SPA (safety if route registration changes)
73+
if strings.HasPrefix(r.URL.Path, "/course/") ||
74+
strings.HasPrefix(r.URL.Path, "/program/") ||
75+
r.URL.Path == "/courses" ||
76+
r.URL.Path == "/sitemap.xml" ||
77+
r.URL.Path == "/robots.txt" {
78+
http.NotFound(w, r)
79+
return
80+
}
6181
// If the file exists, serve it
6282
relPath := strings.TrimPrefix(filepath.Clean(r.URL.Path), "/")
6383
path := filepath.Join(staticDir, relPath)

internal/api/router_seo_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestRouterRobotsTxt(t *testing.T) {
12+
os.Setenv("SITE_BASE_URL", "http://localhost:8080")
13+
handler := NewRouter()
14+
15+
req := httptest.NewRequest(http.MethodGet, "/robots.txt", nil)
16+
rr := httptest.NewRecorder()
17+
handler.ServeHTTP(rr, req)
18+
if rr.Code != http.StatusOK {
19+
t.Fatalf("robots.txt: status %d body %s", rr.Code, rr.Body.String())
20+
}
21+
if !strings.Contains(rr.Body.String(), "Sitemap:") {
22+
t.Errorf("robots.txt body: %s", rr.Body.String())
23+
}
24+
}
25+
26+
func TestRouterSitemapXml(t *testing.T) {
27+
if os.Getenv("DATABASE_URL") == "" {
28+
t.Skip("DATABASE_URL not set")
29+
}
30+
os.Setenv("SITE_BASE_URL", "http://localhost:8080")
31+
handler := NewRouter()
32+
33+
req := httptest.NewRequest(http.MethodGet, "/sitemap.xml", nil)
34+
rr := httptest.NewRecorder()
35+
handler.ServeHTTP(rr, req)
36+
if rr.Code != http.StatusOK {
37+
t.Fatalf("sitemap.xml: status %d body %s", rr.Code, rr.Body.String()[:min(200, rr.Body.Len())])
38+
}
39+
if !strings.Contains(rr.Body.String(), "<urlset") {
40+
t.Errorf("sitemap body: %s", rr.Body.String()[:min(200, rr.Body.Len())])
41+
}
42+
}
43+
44+
func min(a, b int) int {
45+
if a < b {
46+
return a
47+
}
48+
return b
49+
}

0 commit comments

Comments
 (0)