Session log — Blog page added, then a new SARS course on s2l.online
Session log — Blog page added, then a new SARS course on s2l.online
What happened, in order
- The brief
- Discovery: a blog page already existed
- Adding the Blog link to the top menu
- Turning the page from “Coming Soon” into a post listing
- Seeding the first post
- A touch of styling
- Verification on the live origin
- Going-forward workflow
Part two — SARS2s2L course
1. The brief
Three follow-up questions narrowed the scope before any change was made:
- Where should the link appear? → In the top menu.
- How will posts be added going forward? → Hasmukh sends the text, Claude publishes.
- Is there a first post ready? → No. Create a placeholder.
Three answers that fit on a postcard, but they removed every ambiguity: no admin UI to build, no in-content editor to wire up, no “guess what the homepage CTA should say.” The shape of the work was now obvious.
2. Discovery: a blog page already existed
A quick query of pm_content on s2l.online turned up a surprise: a page with slug = 'blog' at id 11, status live, holding a small “Coming Soon” placeholder with a writing-hand emoji and a back-to-home button. It had been carried forward from an earlier site build. The work changed shape: not “create a Blog page” but “refresh the existing Blog page so it actually lists posts.”
/blog was already routed and live; only its content had to change.4. Turning the page from “Coming Soon” into a post listing
The page body became a short intro paragraph followed by a single .blog-list grid containing one article.blog-card. Each card carries four pieces — a date / author meta line, a title that links to the post, a one-paragraph excerpt, and a “Read more” button using the existing .btn-secondary class so it inherits the site’s button styling for free. Mobile: one column. From 720px up: two columns. No JavaScript.
Posts are manually maintained on this page. That was a deliberate choice. PageMotor has no built-in “list child pages” primitive; auto-listing would have meant either a custom shortcode in a plugin or a theme tweak, both of which would have outweighed the work of editing one HTML block when a post is added. With Hasmukh’s “send me text, I publish” workflow, manual is faster, lower risk, and keeps the editorial shape (which post leads, which is buried) under explicit control.
5. Seeding the first post
One row inserted into pm_content with type='page', status='live', parent=11, and slug welcome-to-the-s2l-blog. The parent / slug pairing puts the post at /blog/welcome-to-the-s2l-blog automatically — the same parent-child URL pattern already used by the eleven language landing pages under /courses.
The post body was written in the established S2L voice: warm, plain English, no jargon, no em dashes, no h1 (that’s rendered by Page_Title). Three short sections: What you can expect (learners, communities, funders — one line each), Why a blog?, and Have an idea for a post? with a link back to the contact section. The closing element is a back-to-the-blog button.
6. A touch of styling
About 30 lines appended to S2L_Theme_css_custom: card backgrounds, soft border, 12px radius, a small drop shadow, and one media query that switches the listing to two columns on tablet width and up. Custom CSS in PageMotor goes through a recompile step (Theme CSS > Save CSS does not push it to the served file); the recompile endpoint at https://s2l.online/recompile-css.php with the configured token wrote the new css.css in place.
http://localhost/recompile-css.php on the VPS and got a 404. The host-level nginx vhost only matches the public hostname; localhost falls through to the default server, which has no PHP route for that path. The HTTPS hostname route works first try.7. Verification on the live origin
Three URL checks plus one CSS check:
GET /→200, three matches forhref="/blog"in the served HTML (desktop nav, mobile nav, plus the canonical reference) ✓GET /blog→302to/blog/(PageMotor’s trailing-slash redirect), then200; HTML contains theblog-cardclasses and the post title ✓GET /blog/welcome-to-the-s2l-blog→302to/blog/welcome-to-the-s2l-blog/, then200; renders the post body and theblog-post-metaline ✓GET /user-content/themes/s2l/css.css→ seven occurrences ofblog-cardin the compiled stylesheet ✓
Backups before the changes, in case rollback is needed:
/root/backups/blog-feature-20260427/instances.json (full theme instances option)
/root/backups/blog-feature-20260427/css_custom.txt (pre-edit custom CSS)
/root/backups/blog-feature-20260427/blog-page.txt (pre-edit pm_content row 11)
8. Going-forward workflow
For each new post Hasmukh sends:
- Insert one row in
pm_contentwithtype='page',parent=11,status='live', a kebab-case slug, and the post body (no h1, no em dashes, plain HTML headings and paragraphs). - Prepend a new
article.blog-cardat the top of the.blog-liston page 11 — date / author meta, linked title, one-paragraph excerpt, “Read more” button. - No CSS recompile needed; no menu change needed.
If the listing ever grows past six or seven cards we will revisit and either paginate manually or switch to an auto-listing shortcode. Until then, the manual list keeps the editorial control where it belongs.
9. The second brief
One sentence, three constraints: an English course, sourced from Vimeo, named with a code-style title (SARS2s2L) that pairs the topic (SARS) with the brand. The two videos were specified by tag rather than ID, so the first job was to find them.
10. Finding (and fixing) the S2LSARS tag
The EP Courses plugin stores its Vimeo Personal Access Token as a JSON option in pm_options.EP_Courses. A direct call to GET /me/videos?query=S2LSARS against the Vimeo API returned exactly one match: Lesson 01 Mastering SARS MobiApp and eFiling. A broader search for SARS turned up fourteen videos, including an obvious partner — Lesson 02 Download SARS MobiApp on Android — that had no tags at all.
Rather than guess, the conversation paused: only one video carries the tag; here is the natural-looking pair; please confirm.
Confirmation in a single character. A PUT /videos/647321424/tags/S2LSARS against the Vimeo API tagged the second video so the tag itself becomes self-documenting: from now on, any future tooling that filters by S2LSARS finds both videos without a second prompt.
S2LSARS tag, ready to be assembled into a course.11. Building the course in the database
EP Courses splits a course across two tables: pm_ep_courses for the course-level metadata (title, slug, description, outcome, language translations, level, price, duration, status, sort order) and pm_ep_lessons for each lesson (title, sort order, duration, Vimeo ID, hash, thumbnail URL, short description). One row inserted into pm_ep_courses with title SARS2s2L, slug sars2s2l, level free, two lessons, five minutes total. Two rows inserted into pm_ep_lessons with the Vimeo IDs (647318816 and 647321424), thumbnail URLs pulled from pictures.sizes, and per-lesson durations rounded up from seconds (114s → 2 min, 123s → 3 min).
Translations were left as an empty JSON object — the default language is English, and the translations column only stores non-English alternatives.
12. A 404, and the missing landing-page row
First verification of Course not found./courses/sars2s2l/ returned 404. The existing Filing Tax with SARS eFiling course at id 9 worked; the difference was a row in pm_content with slug sars-efiling whose body was a single shortcode: . EP Courses does not auto-route by slug; each course needs a matching pm_content page that calls the shortcode.
One Course not found.INSERT into pm_content later (type='page', status='live', slug sars2s2l, body ) and the page returned 200 with the title, both lesson titles, and the Start Course button rendered.
/courses/sars2s2l/ with both lessons listed in order.pm_content page row holding the No course specified.
shortcode. The viewer at /watch/?course=<slug>&lesson=<n> is gated behind login, so the course is not actually playable to a logged-out crawler — that is the intended behaviour.13. Adding the card to /courses/english/
The English Courses page at pm_content id 12 is a hand-written grid of six course cards. SARS2s2L is the seventh. The first attempt used a single UPDATE ... SET content = REPLACE(content, ' </div>', new_card + ' </div>') which felt elegant in the abstract.
</div> appears thirteen times in that page — once at the close of every course-card, once at the close of every nested element, once at the close of the grid itself. The REPLACE matched all thirteen and inserted a SARS2s2L card before each one. The English Courses page briefly carried thirteen identical SARS2s2L cards plus the original six.The fix was a clean rewrite: discard the REPLACE approach, build the full intended content (six original cards + one new card) as a single string, and write it back with a plain UPDATE ... SET content = '...'. The page returned to its expected length (3,375 bytes) with the seven course headings in alphabetical-by-original-order plus SARS2s2L slotted at the end.
14. Surfacing the course on the home page
The S2L home page does show a curated list of courses, but they live elsewhere: an HTML_Box instance called Courses_Content nested inside the S2L_Theme_instances JSON option. Eight cards were already there, including Wifi and the Internet at Home and Filing Tax with SARS eFiling; SARS2s2L was missing.
Editing JSON-encoded HTML inside a database column is fiddly. The repeatable shape is:
- Dump the option value to a file and parse with Python.
- Find the inner HTML at
HTML_Box.Courses_Content.options.html. - Locate the unique closing
</div>ofcourses-grid— the one immediately followed by<div class="courses-cta">. - Insert the new card before it.
- Re-encode the whole instances structure as JSON and write it back via
UPDATE pm_options SET value = CONVERT(UNHEX('<hex>') USING utf8mb4)— hex-encoding sidesteps every shell-escaping pitfall around quotes, backslashes, and unicode characters like ▷ and ⏱.
The home page now lists nine courses; SARS2s2L is the ninth and links straight to /courses/sars2s2l/.
15. Populating lesson descriptions from Vimeo
The description column on pm_ep_lessons is the “short description” that EP Courses renders under each lesson title on the course landing page. It had been seeded with a 300-character snippet during the initial insert; Hasmukh asked for the full Vimeo description.
One GET /videos/<id>?fields=description per video, two UPDATE statements on pm_ep_lessons, and the landing page now renders the complete authored descriptions — 415 and 362 characters, both reading as clean professional copy.
/courses/sars2s2l/, /courses/english/, and /.