Skip to content

refactor(faq): make FaqSection self-contained; emit FAQPage JSON-LD#539

Open
rusikv wants to merge 20 commits into
thingsboard:mainfrom
rusikv:refactor/faq-self-contained
Open

refactor(faq): make FaqSection self-contained; emit FAQPage JSON-LD#539
rusikv wants to merge 20 commits into
thingsboard:mainfrom
rusikv:refactor/faq-self-contained

Conversation

@rusikv

@rusikv rusikv commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Important

Stacked on #536 (perf/lighthouse-core-web-vitals) — merge that PR first. Until it merges, the diff below includes its commits; the actual change here is the single commit 8baca79cd.

What

Pricing/FaqSection.astro becomes fully self-contained — markup, styles, behavior, and structured data in one place:

  • One shared script (document-delegated, runs once per page) drives every FAQ instance: accordion, category tabs, "Load more", copy-link, and idle-deferred #hash deep-links. The duplicated copies are gone: ~150 lines of inline handlers on the pricing page plus the whole iot-hub-faq.ts / FaqJsonLd.astro pair.
  • window.__tbFaqOpen(id) is the small public API the pricing page uses to open an item after activating the owning product/sub-tab (its FAQ blocks are display:none until then).
  • FAQPage JSON-LD via a new jsonLd prop backed by src/util/faq-schema.ts (HTML stripped to plain text, entities decoded, < escaped so answer content can never terminate the script element). IoT Hub keeps its existing schema; /pricing/ gains structured data for the first time — one block for the default-visible Public Cloud context (91 Q&A, ~28 KB raw).
  • Analytics prop (opt-in): pricing keeps faq_copy_link / faq_node_interaction; IoT Hub stays silent — both exactly as before.

A11y fixes (in the shared markup, so all consumers benefit)

  • Copy-link is now a real <button> — it was a span[role=button] nested inside the question <button> (invalid interactive-in-interactive) and keyboard-dead on pricing. Enter/Space now work everywhere and the control reveals on :focus-visible.
  • The h3 wraps only the toggle, so the heading's accessible name is the question alone.
  • aria-controls wired to new answer ids; aria-current SSR'd on the active category tab.
  • Fixed a duplicate DOM id in the self-managed FAQ data (pe-pay-as-you-go-cancel-subscription × 2 — the second item was unreachable by deep link).

Deliberate changes to flag

  • Marketing/analytics: repeat jumps to an already-open FAQ item no longer re-push faq_node_interaction — the event now counts opens only, matching the accordion handler's documented semantics.
  • SEO: the pricing JSON-LD covers only the default-visible context (one FAQPage per page; the other 7 contexts are display:none until a pricing tab reveals them). Marking up all 8 would add ~194 KB to the page.
  • Design: the copy-link icon moved from "inline after the question text" to the row's right rail next to the chevron — required to un-nest it from the toggle button.

Verification

  • astro check 0 errors; ESLint clean.
  • 27-check puppeteer suite against the production build: accordion/tabs/load-more/copy on both pages, #hash deep-link into a hidden pricing context, ?faqSection=, tooltip (i)-icon jumps, data-open-calc links, analytics event counts (pricing unchanged, IoT Hub zero), exactly one FAQPage block per route, and collision safety for the unrelated .faq-* implementations (partners/affiliate, Trendz) — all green.
  • Visual states screenshotted in light + dark: hover, expanded, keyboard focus rings.

rusikv added 20 commits July 1, 2026 18:09
The site has no View Transitions/ClientRouter, so astro:page-load never fires. Each setup already inits on DOMContentLoaded (or immediately) and is idempotent, so the extra listener was dead registration.
…nk rewrite

MailerLite universal.js + ml('load') now load on first interaction via __deferOnInteraction instead of at parse; UtmTracker's document.links rewrite runs at requestIdleCallback instead of DOMContentLoaded. Moves third-party/main-thread work out of the load/LCP window.
Re-encode reporting_12-1.webp at q80 (same 2400x1512 dims) — the LCP element on the 4.2 release post. Visually identical for a UI screenshot.
…walk

Hero highlight (3s) and gallery autoplay (5s) setInterval timers start only when their element scrolls into view (IntersectionObserver), so they never run off-screen; FAQ deep-link hash walk + scrollIntoView deferred to requestIdleCallback.
GTM already defers to first interaction via __deferOnInteraction; this raises the no-interaction fallback so tag-manager + injected pixels bootstrap after the LCP window for idle visitors. Trade-off: analytics for non-interacting visitors fires up to ~2.5s later.
InstallButton now imports a tiny boot module that wires one delegated click listener and dynamic-imports the dialog on first trigger click. The dialog module no longer self-initializes; openFor() reads the stored local base on first open. Drops the iot-hub model chunk (~77KB raw) from the eager graph on detail and solution-template pages: eager JS 96.6->16.5KB raw (29.9->7.5KB gz), verified against the built output.
The 3s fallback assumed GTM's old 2.5s; bump to 5.5s so the intended GTM-first ordering holds for non-interacting visitors.
Same path and format (webp q80 / png palette), dimensions unchanged, visually verified: 7.6MB -> 1.8MB total. Two files that carried PNG bytes under a .webp extension are now real WebP. The remaining >300KB files were measured and skipped (recompression gains <20% or negative).
Blog-post body images rendered as bare <img> with no dimensions and eager loading. New rehype plugin (scoped to src/content/blog/) injects intrinsic width/height read from public/ at build time, keeps the first body image eager (usual LCP element), and lazy-loads the rest with decoding=async. Verified in the built output: docs pages byte-identical, every post <=1 eager image.
The header mega-menu inlined 50+ icon SVGs (~30KB gz) ahead of <main> on every page. Icons now live in one cached /nav-sprite.svg referenced via <use>; currentColor theming and per-icon aspect ratios are preserved. Regenerate with pnpm generate:nav-sprite after editing icons.
DocImage and ImageGallery hardcoded loading=lazy on every image, including a page's first content image — its likely LCP element. A per-render Astro.locals flag now marks the first image eager + fetchpriority=high (dark-theme twins load eager without the priority boost); everything after stays lazy.
196KB -> 180KB at q80, same dimensions.
Each of ~500 FAQ rows inlined two identical tabler SVGs (chevron + copy-link). Both are now shared CSS mask definitions colored via currentColor — about 170KB of markup and ~2,000 DOM nodes off /pricing/ with identical rendering, including the copied state.
Same rationale as the iot-hub batch: no ClientRouter, the event never fires; both scripts already init on DOMContentLoaded or immediately.
The deferred callback almost always runs after DOMContentLoaded, so a DCL listener registered inside it never fired and the form never received its GTM id/class for first-touch visitors. Also make the 2500ms fallback explicit: the visible demo form deliberately embeds ahead of GTM's 5s tier.
Two rapid clicks racing the dynamic import both reached showModal(), which throws on an open dialog; open() now returns early. A rejected import is logged instead of silently swallowing the click. Also simplify: reading the local base at module scope is already first-click-lazy (the boot module's import() is the only consumer), and a document-level delegated listener needs no DOM-ready gate.
decodeURIComponent throws on a literal % in a filename and would fail the whole build; degrade to skipping dimensions like every other failure path. Plus comment/type tidying (dev-mode cache staleness note, drop a no-op NonNullable).
The generator now also writes a manifest (symbol id + viewBox) that NavIcon imports, so reference and geometry come from the same generation pass instead of NavIcon re-deriving them from raw sources. Build scripts run the generator (fast, 37 files) so a stale committed sprite can't ship; outputs carry do-not-edit markers. Sprite stays committed so dev works without a build.
- DeferredLoadTrigger now carries the authoritative idle-fallback schedule; GTM and GitHubButton comments point at it instead of cross-referencing each other.
- FAQ hash deep-link and UTM link rewrite get idle timeouts (500ms/1000ms) so a busy main thread can't unboundedly delay a deep-link scroll or lose click attribution.
- Drop the fetchpriority boost from first content images (eager stays): on text-first docs pages a below-fold image shouldn't compete with resources painting the fold.
- Gallery autoplay: the never-cleared timer already makes start one-shot; drop the redundant flag and cross-reference the two visibility-gate copies.
- FAQ mask data-URIs deduped into SCSS variables and pinned to CanvasText under forced-colors so the glyphs stay visible in high-contrast modes.
Move the generic FAQ behavior (accordion, category tabs, load more,
copy-link, hash deep-link) into a script co-located with the component;
delete the duplicated copies (pricing inline handlers, iot-hub-faq.ts).
The pricing page keeps only its page-specific parts and opens items
through the new window.__tbFaqOpen API after activating the owning
product tab.

- New util builds schema.org FAQPage JSON (HTML stripped to plain text,
  entities decoded, `<` escaped so answer content can never terminate
  the script element); FaqSection emits it via a jsonLd prop. IoT Hub
  pages keep their existing schema (FaqJsonLd.astro absorbed);
  /pricing/ gains one block for the default-visible Public Cloud
  context.
- A11y: copy-link is a real button (was span[role=button] nested inside
  the question button - invalid nesting, and keyboard-dead on pricing);
  the heading wraps only the toggle so its accessible name is the
  question alone; aria-controls wired to new answer ids; aria-current
  SSR'd on active category tabs; focus-visible styles on both buttons.
- Analytics via an opt-in prop: pricing keeps faq_copy_link /
  faq_node_interaction, iot-hub stays silent (both as before). One
  deliberate change: repeat jumps to an already-open item no longer
  re-push faq_node_interaction (opens only, matching the accordion
  handler's documented semantics).
- Fix a duplicate DOM id in the self-managed FAQ data (the second item
  was unreachable by deep link and blocked aria-controls).
@rusikv rusikv requested a review from vvlladd28 July 8, 2026 12:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant