Skip to content

SDD-029 · Dựng sẵn HTML, accessibility và cụm bài viết SEO ​

Một phần của SDD-029. Dữ liệu API dựng sẵn vào HTML, accessibility, prerender bằng React, rà SEO trang cũ, cụm bài viết SEO trong D1.

Lịch sử quyết định ​

Dữ liệu API công khai dựng sẵn vào HTML (SRC-1170, 30.09.2026) ​

Sau SRC-1154, rà SEO trên production còn 11 trang dưới 150 chữ trong HTML tĩnh: các trang đọc API (đội ngũ mentor, câu chuyện, cộng đồng) dựng sẵn lúc build khi chưa có dữ liệu, nên chỉ có khung giữ chỗ và bot không đọc được tên hay giới thiệu của ai.

  • apps/web/src/bootData.ts: kho dữ liệu dựng sẵn, khoá là đường dẫn API, giá trị là phong bì JSON. Lúc build apps/web/scripts/prerender.mjs gọi 10 API công khai (mentors, albums, stories theo ba nhóm người đọc, testimonials) và nạp kho; mỗi trang chỉ nhúng đúng những khoá nó đã đọc, vào <script type="application/json" id="n12-boot">. Trên trình duyệt kho đọc khối ấy, nên lần render đầu của React trùng bản dựng sẵn; component vẫn gọi API để lấy bản mới nhất.
  • Component đọc qua publicApi.peekMentors/peekAlbums/peekStories/peekTestimonials ở state đầu (Team.tsx, IeltsMentorsPage.tsx, StoriesPages.tsx).
  • KHÔNG nạp bảng giá: giá đổi theo ngày (mức tháng 9 hết hạn 01.10.2026), nhúng lúc build là có thể hiện giá đã hết hạn. API hỏng lúc build thì trang dựng khung giữ chỗ như trước.
  • Stagger bắt đầu ở trạng thái đã hiện trong lượt tải đầu có bản dựng sẵn: bắt đầu ở "an" thì thẻ con đứng ở opacity 0, chữ có trong HTML mà màn trống.
  • /mentors: h2 ẩn "Đội ngũ mentor" trước danh sách (tên từng người là h3; heading-order).
  • EssayPriceCard (trang học phí Essay mới, chép khuôn Grammar CŨ) mang lại đúng lỗi SRC-1154 đã sửa ở Grammar: đang tải thì nói "đang được cập nhật", thẻ giá về đẩy cả trang (CLS 0,126). Sửa như Grammar: null là đang tải, khung giữ chỗ cao bằng thẻ thật (272 / 239 px). CLS 0.
  • Đo ở máy: /community 126 -> 988 chữ, /stories 141 -> 379, /mentors 120 -> 185; bản dựng sẵn và bản React của các trang này trùng nhau, CLS 0.

Accessibility trang công khai, và dựng sẵn các trang cũ (SRC-1163, 30.09.2026) ​

Đo trên production sau SRC-1154 (Lighthouse mobile, Accessibility): trang chủ 92, /ielts/band 92, học phí Grammar 94, /parents 96. Sửa theo từng loại lỗi, ở chỗ gốc chứ không từng trang:

  • color-contrast: coral --accent chỉ 3,29:1 trên nền trắng; DS-001 dành nó cho viền, mũi tên, chấm, và dành --accent-deep (4,80:1) cho chữ. Trang công khai dùng text-accent cho chữ hơn trăm chỗ, nên sửa một lần ở tầng theme (apps/web/src/index.css): trên nền sáng text-accent lấy --accent-deep, trên .n12-band-dark giữ coral gốc. Link trong thanh cookie (nền xám nhạt, 4,35:1) dùng --accent-ink. Hai nút viền ở chân trang nền tối ("Chuyển sang", "Chọn vai") mang chữ màu của nền sáng (1,95:1): thêm text-ocean-900 như các chữ khác ở chân trang.
  • aria-allowed-attr: thẻ chọn vai của RoleGate là LINK mà mang aria-pressed; đổi sang aria-current.
  • label-content-name-mismatch: nút menu hiện chữ "Menu" nhưng aria-label là "Mở điều hướng"; bỏ aria-label, chữ trên nút là tên.
  • heading-order: PricingList chế độ bare (bảng giá đứng thẳng dưới h1: Grammar, Essay) dùng h2 cho nhóm thay vì h3.
  • link-name: mục "Đọc tiếp" bị gập đặt hidden lên thẻ BÊN TRONG link, để lại link rỗng mà phím Tab vẫn dừng vào; nay hidden bọc cả mục.
  • Trang cũ ngoài bảng route (LEGACY_META: /mentors, /community, /privacy, /specialist-schools, năm trang linh vật) nay cũng dựng sẵn bằng React như trang app; /login (noindex) giữ bản tối thiểu. /mentors từ 15 chữ lên ~120 chữ trong HTML tĩnh.

Đo ở máy: Accessibility 100 trên trang chủ, /parents, /sat, /ielts/band, /mentors, học phí Grammar, /teachers/celta.

Độ dài tiêu đề và mô tả của TOÀN BỘ sitemap (bổ sung SRC-1150): quét 436 trang không có canonical (trừ cụm /{service}/articles/** sinh từ D1), 119 trang lệch chuẩn (tiêu đề 30 tới 60 ký tự gồm hậu tố, mô tả 110 tới 160), nay 0. Sửa tại nguồn: apps/web/src/site/routes.ts (trang viết tay, và các nhóm sinh hàng loạt qua fitTitle/fitDescription) và các file apps/web/content/seo/{ielts,celta,tesol,tefl}/*. Trang nội dung dùng tiêu đề làm h1, nên vài h1 đổi theo; không slug nào đổi.

Trang app dựng sẵn bằng chính React (SRC-1151 trang chủ, SRC-1154 mọi route, 30.09.2026) ​

Đo trước khi sửa (production, Lighthouse mobile): trang chủ 70 (LCP 6,1 s, phần tử LCP là chữ của THANH COOKIE, thứ hiện sau cùng), /parents 77, /students 73, /teachers 75, /students/ielts/tuition 71. Bản tĩnh của trang app là phần chữ tóm tắt cho bot, khác hẳn bố cục React, nên không áp được khung tĩnh chép tay của trang nội dung (SRC-1149).

  • Render thẳng <App /> cho từng đường dẫn lúc build bằng react-dom/static (apps/web/src/ssrApp.tsx), thứ chờ được các chunk lazy(). Trang chủ (SRC-1151) làm trước với RoleGate + CookieNotice, rồi gộp vào cùng đường. apps/web/scripts/prerender.mjs đặt kết quả vào #n12-static và dùng lại cơ chế đổi bản của apps/web/src/staticHandoff.ts. Render hỏng thì trang lùi về bản cũ (chữ tóm tắt, CSS chặn vẽ), không đánh rơi trang.
  • Node không có DOM: một window tối thiểu cho đúng phần code chạy lúc RENDER (đường dẫn, localStorage rỗng, matchMedia); effect không chạy khi render phía server. React để ngoài bundle, mainFields ưu tiên bản ESM của các gói (bản CommonJS của lucide-react làm hỏng render).
  • CSS tối thiểu riêng từng trang, tính từ lớp và thẻ của chính markup ấy.
  • Bản dựng là trạng thái NGƯỜI MỚI. Script đồng bộ trong <head> gắn n12-has-role / n12-has-consent lên <html> trước lần vẽ đầu; CSS nhúng ẩn thanh cookie với người đã chọn, và màn chọn vai ở trang chủ với người đã chọn vai.
  • Appear/FadeIn không chạy trong bản dựng sẵn và lượt tải đầu có bản tĩnh.
  • SEO kèm theo: bot đọc toàn bộ nội dung thật của trang app thay vì phần tóm tắt (trước đó 89 trang có dưới 150 chữ trong HTML tĩnh).
  • Đo ở máy: trang chủ 100, /parents 97, /students 97, /students/university/ielts 95, /teachers 97, /students/ielts/tuition 100, /about 100, /education 100; CLS 0.

Rà SEO các trang cũ (SRC-1150, 30.09.2026) ​

Rà 461 URL trong sáu sitemap cũ trên production (tiêu đề, mô tả, canonical, h1, noindex, og:image, số chữ trong HTML tĩnh). Bốn nhóm lỗi đã sửa:

  • Trang trùng lặp: một bản chính mỗi nhóm. 33 cặp /students/... và /parents/... cùng tiêu đề, cùng mô tả, nội dung lệch chưa tới 1%, cộng /ielts/tuition trùng hai bản theo vai; mỗi bản tự khai canonical về chính nó. withCanonicals trong apps/web/src/site/routes.ts chọn bản chính cho mỗi nhóm cùng tiêu đề (ngắn nhất sau khi bỏ tiền tố vai, bằng nhau thì bản học sinh) và điền canonical cho các bản còn lại. Prerender và useSeo khai canonical theo trường đó; sitemap chỉ liệt kê bản chính. Các bản phụ vẫn mở được bình thường.
  • Mười trang cũ tự khai là trang chủ. /mentors, /community, /privacy, /specialist-schools, /login và năm trang linh vật nằm trong sitemap nhưng không được prerender, nên worker trả vỏ SPA mang tiêu đề và canonical của trang chủ. Nay LEGACY_META (routes.ts) giữ tiêu đề, mô tả, đoạn mở đầu cho từng trang, lấy từ đúng chữ đang hiện trên trang; prerender dựng mỗi trang một file; App.tsx đặt tiêu đề từ cùng bảng ấy. /login mang noindex và rời sitemap.
  • Độ dài tiêu đề và mô tả. Khoảng 80 trang ngoài chuẩn (tiêu đề 30 tới 60 ký tự gồm hậu tố, mô tả 110 tới 160) được viết lại tại nguồn; nhóm sinh hàng loạt đi qua fitTitle/fitDescription trong routes.ts, chọn câu ứng viên đầu tiên lọt khung từ chính chữ của trang.
  • Khung tĩnh của trang nội dung khớp cả thân bài (bổ sung SRC-1149): thân bài dựng đúng markup Prose/section/Bullets, có chỗ cho khối video dưới phần mở đầu, và FadeIn không chạy hiệu ứng trong lượt tải đầu có khung tĩnh (isStaticBoot trong apps/web/src/staticHandoff.ts), vì cho chữ đang hiện mờ đi rồi hiện lại là một lần chớp, và Chrome tính lần hiện lại là ứng viên LCP. Đo: /ielts/reading/true-false-not-given từ 83 lên 100; 224/224 trang lệch 0 px ở cả h1 lẫn h2 đầu thân bài.

Còn lại, chưa sửa: 89 trang app có dưới 150 chữ trong HTML tĩnh (bản cho bot của trang app là phần chữ tóm tắt, React dựng phần còn lại); và ~177 trang ngoài danh sách đã rà vẫn lệch chuẩn độ dài.

Trang nội dung vẽ từ bản tĩnh, React thay vào sau (SRC-1149, 30.09.2026) ​

Chủ dự án: nâng /sat lên PageSpeed mobile >= 95. Đo trước khi sửa: Performance 81, FCP 2,4 s, LCP 4,2 s. Hai nguyên nhân, cả hai nằm ở cách bản prerender nhường chỗ cho React:

  1. React xoá chữ tĩnh trước khi nó kịp hiện. Chữ tĩnh nằm trong #root, gói JS về kịp trước lần vẽ đầu, và createRoot().render() thay sạch #root; nội dung chỉ hiện sau khi thêm một chunk lazy về. LCP đứng sau cả chuỗi HTML, gói ~200 KB, chunk trang.
  2. Lần vẽ đầu chờ CSS và JS. File CSS 97 KB (21 KB nén) chặn vẽ, và mô phỏng mạng chậm của Lighthouse tính mọi request bắt đầu trước lần vẽ đầu (kể cả gói JS có modulepreload) vào FCP.

Cách làm, CHỈ cho 224 trang của các họ trang nội dung (apps/web/content/seo):

  • Khung tĩnh ngoài #root (staticShell trong apps/web/scripts/prerender.mjs): phần mở đầu dùng đúng lớp của khuôn hero trong apps/web/src/site/Kit.tsx, breadcrumb dựng đúng markup của Crumbs, thanh nav giữ chỗ cao bằng SiteNav. Đo trên cả 224 trang: tiêu đề h1 của bản tĩnh và bản React lệch 0 px.
  • React vẽ vào #root đã tách khỏi tài liệu, rồi được gắn thay chỗ khung tĩnh trong cùng một khung hình khi nội dung thật đã commit (apps/web/src/staticHandoff.ts, gọi từ StaticHandoff trong Suspense của apps/web/src/App.tsx). Không dùng hidden: Chrome tính phần tử có sẵn khung 0x0 mà hiện ra là một lần dịch chuyển (đo được CLS 0,237); phần tử mới chèn vào thì không.
  • CSS tối thiểu nhúng sẵn (~14 KB, 4 KB nén): Tailwind biên dịch apps/web/src/index.css với đúng các lớp khung tĩnh dùng, rồi bỏ mọi luật có selector nhắc tới lớp hay thẻ không có trong khung.
  • CSS đầy đủ và gói JS chỉ bắt đầu tải sau khung hình đầu tiên (withStaticHead); bước đổi sang bản React chờ CSS đầy đủ xong, để không lộ trang chưa có kiểu.
  • Vỏ SPA sạch spa-shell.html cho đường dẫn động (/mentors/{slug}...): index.html nay là prerender trang chủ, dùng nó làm vỏ là cho các trang ấy chớp nội dung trang chủ.
  • Trang app (ROUTES) giữ cách cũ. Bản tĩnh của chúng là phần chữ tóm tắt cho bot, KHÁC bố cục và cả tiêu đề của bản React; giữ nó trên màn cho tới khi React xong là cho người đọc thấy một trang khác rồi đổi. Muốn áp cho nhóm này thì bản tĩnh phải dựng đúng bố cục của từng trang trước.

Kết quả đo ở máy (Lighthouse mobile): trang nội dung Performance 100, FCP ~0,9 s, LCP ~1,1 s. /sat ở máy còn CLS do khối video tự ẩn khi api.nemo12.com chặn CORS từ 127.0.0.1; trên tên miền thật video tải được và CLS bằng 0.

Cụm bài viết SEO theo dịch vụ, nội dung trong D1 (SRC-1132, 29.09.2026) ​

Chủ dự án: làm SEO cho NEMO IELTS, NEMO SAT, NEMO SPEAK, NEMO GRAMMAR; mỗi dịch vụ 100 bài chia 5 category, mỗi cụm xoáy vào một Job To Be Done của một nhóm audience, một dòng đi theo từng bước hành trình; ảnh bìa và hình minh hoạ sinh bằng thuật toán dạng SVG, có bản mobile và desktop; toàn bộ nội dung nằm trong D1, mỗi bài một URL; PageSpeed mobile trên 95; chuyển thành skill.

  • URL (quyết một lần, không đổi):

    TầngĐường dẫn
    Hub dịch vụ/{service}/articles
    Category/{service}/articles/{category}
    Bài/{service}/articles/{category}/{slug}
    RSS đủ nội dung (Substack nhập được)/{service}/articles/feed.xml
    Sitemap con/sitemap-articles-{service}.xml (sitemap index trỏ tới)

    {service} ∈ ielts, sat, speaking, grammar, trùng đường dẫn sản phẩm trên learn. Slug tiếng Anh (SRC-604), 2 tới 8 từ, mở đầu bằng keyword, không năm, không số thứ tự. Category nằm trong URL nên bài không đổi category sau khi xuất bản.

  • Năm category mỗi dịch vụ: bốn jtbd (mỗi cái một job của một audience, luôn có một cho phụ huynh) và một journey (20 bước theo thứ tự). Mỗi bài jtbd mang một góc nhìn: job-map, pain, gain, fear, need, solution, myth, mistake, compare, checklist, story, metric, decision.

  • Nội dung sống trong D1 (migration 0313: article_services, article_categories, articles). Markdown ở apps/web/content/articles/ là bản SOẠN; apps/web/scripts/articles/seed.mjs sinh scripts/seed-articles-{service}.sql (idempotent, bài gỡ khỏi repo thành draft), nạp qua workflow seed-data. body_html dựng sẵn lúc seed, nên worker không mang bộ đọc Markdown.

  • Worker nemo12-web dựng trang từ D1 (apps/web/worker/articles/), cache edge 1 giờ; URL không có dòng trong D1 trả 404 thật. Trang KHÔNG có JavaScript, CSS Tailwind (token DS) nhúng ~14 KB. Lý do: trang SPA /sat đo được 68 điểm Performance mobile (LCP 6,4 s) vì gói React; trang bài dựng thử đo 100/100/100/100.

  • Hình: worker/articles/art.ts, hàm thuần. Bìa 1200x630 (desktop, chia sẻ) và 800x800 (mobile); 8 dạng hình (steps, bars, compare, cycle, matrix, ladder, funnel, timeline), mỗi hình hai bản 800 và 480, chọn bằng <picture> ở mốc 640px; cycle và matrix đổi hẳn sang bố cục dọc ở bản mobile.

  • Cổng scripts/check-articles.mjs (trong check:code): slug, độ dài title/description, trùng title/description, >= 1100 từ, >= 2 hình vẽ được ở cả hai bản, >= 3 link nội bộ không gãy, không em/en dash, nhắc Nemo <= 5, trùng cụm 5 từ <= 12%, khoá lạ trong dữ liệu hình, seed khớp Markdown.

  • Skills: /article-plan, /article-write, /article-publish.

  • Khác với SRC-1103: SRC-1103 là vài bài dài viết tay trong mảng TS, đặt dưới /{vai}/university/{môn}/articles; SRC-1132 là cụm SEO sinh theo kế hoạch, sống trong D1. Hai không gian URL không giao nhau.

  • Ảnh chia sẻ PNG (og:image, twitter:image, JSON-LD, RSS): mạng xã hội không hiện SVG, nên worker render chính SVG bìa ra cover.png 1200x630 bằng @resvg/resvg-wasm (worker/articles/png.ts), font nhúng Be Vietnam Pro (OFL-1.1, 400 + 800) vì worker không có font hệ thống; cache edge như mọi ảnh khác. Ảnh hiển thị trong trang vẫn là SVG (nhẹ hơn). Worker nén ~1,2 MB.