Session log — Blog page added, then a new SARS course on s2l.online

← All session logs

Session log — Blog page added, then a new SARS course on s2l.online

27 April 2026 · Hasmukh with Claude · two short pieces of work in one day: (1) stand up a publishable blog on s2l.online, surface it in the top menu, and seed it with a friendly placeholder post; (2) build a brand-new English course called SARS2s2L from two Vimeo videos, wire up the landing page, and surface it on both the English Courses page and the home page.

Brief

1. The brief

HasmukhI want to add a page to my web site for posting blogs. It should have a link to the blog page on the home page.

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.

Step 1

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.”

OutcomeOne fewer page to insert. The slug /blog was already routed and live; only its content had to change.
Step 3

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.

Step 4

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.

OutcomeA real, readable first post that fits the site’s tone and gives the listing page something honest to point at on day one.
Step 5

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.

Note for next timeThe first attempt to recompile hit 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.
Step 6

7. Verification on the live origin

Three URL checks plus one CSS check:

  • GET /200, three matches for href="/blog" in the served HTML (desktop nav, mobile nav, plus the canonical reference)  
  • GET /blog302 to /blog/ (PageMotor’s trailing-slash redirect), then 200; HTML contains the blog-card classes and the post title  
  • GET /blog/welcome-to-the-s2l-blog302 to /blog/welcome-to-the-s2l-blog/, then 200; renders the post body and the blog-post-meta line  
  • GET /user-content/themes/s2l/css.css → seven occurrences of blog-card in 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)
Going forward

8. Going-forward workflow

For each new post Hasmukh sends:

  1. Insert one row in pm_content with type='page', parent=11, status='live', a kebab-case slug, and the post body (no h1, no em dashes, plain HTML headings and paragraphs).
  2. Prepend a new article.blog-card at the top of the .blog-list on page 11 — date / author meta, linked title, one-paragraph excerpt, “Read more” button.
  3. 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.

Later that day · Brief

9. The second brief

HasmukhBuild a new English course from Vimeo with these two videos with tag S2LSARS and name the course SARS2s2L.

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.

Step 1

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.

Hasmukh1.

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.

OutcomeTwo Vimeo videos sharing the S2LSARS tag, ready to be assembled into a course.
Step 2

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.

Step 3

12. A 404, and the missing landing-page row

First verification of /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:

Course not found.

. EP Courses does not auto-route by slug; each course needs a matching pm_content page that calls the shortcode.

One INSERT into pm_content later (type='page', status='live', slug sars2s2l, body

Course not found.

) and the page returned 200 with the title, both lesson titles, and the Start Course button rendered.

OutcomeCourse landing live at /courses/sars2s2l/ with both lessons listed in order.
Note for next courseFor each new course, three writes are needed, not two: course row, lesson rows, and a 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.
Step 4

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.

The mistakeThe string </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.

LessonFor inserting one new item into a hand-written list, do not search-and-replace on a structural marker that repeats — rebuild the whole block from a known-good source. Faster, safer, and the diff is obvious.
Step 5

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:

  1. Dump the option value to a file and parse with Python.
  2. Find the inner HTML at HTML_Box.Courses_Content.options.html.
  3. Locate the unique closing </div> of courses-grid — the one immediately followed by <div class="courses-cta">.
  4. Insert the new card before it.
  5. 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/.

Step 6

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.

OutcomeSARS2s2L is fully wired: course row, lesson rows with full descriptions, landing-page row, English Courses card, home page card. Three URLs verified live: /courses/sars2s2l/, /courses/english/, and /.