Phase 1 — internal linking:
- Real [related] index (tags/keywords/category) in hugo.toml; rewrite
related-posts.html to use .Related instead of "first 4 posts", which
had left 12/20 posts with zero inbound links.
- Add tags to 6 previously untagged posts.
Phase 2 — de-cannibalization of the transactional cluster:
- Soft-301 mechanism (redirectTo -> meta refresh + noindex,follow) in baseof.
- xslt-online-editor-guide redirects to the app (pure duplicate of home).
- xslt-validator-online retargeted informational; drop the "common errors"
section that cannibalized the errors hub, link to it instead.
- Migrate why-xslt-playground-online-editor into /about/ with an alias.
- Remove the duplicate <h1> on the app landing page.
Phase 3 — consolidate authority on the money domain:
- baseURL -> https://xsltplayground.com/blog/ (served via NPM reverse
proxy to GitHub Pages; subdomain will 301 to the subpath).
- Rewrite internal blog links to /blog/... and cross-domain app links
to xsltplayground.com/blog/...
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
GSC shows the dedicated function pages already rank well — xpath-contains
at pos 3.3, xpath-substring at pos 5.8 — but the string-functions roundup
post competed with them for the same terms and often outranked the better
page at a worse position (e.g. it took "substring" impressions at pos 17
while xpath-substring sat at pos 5.8). Classic self-cannibalization.
- string-functions: link every function in the at-a-glance table to its
dedicated /xslt/functions/ reference page (18 of 19 exist). The roundup
now consolidates authority into the pages that already win and keeps the
head term "xslt string functions" for itself, instead of splitting
signals across both.
- template-matching (ranks pos 5.4 for "template match", no cannibalism):
link the "mode" and "apply-templates" mentions to their reference pages —
it previously linked to nothing but the homepage.
Hugo build verified locally (345 pages, exit 0); links resolve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
Clarity showed dead clicks on 38% of sessions, all on desktop. Reproduced
the hotspots in a headless browser: the footer logo/brand, the "Input"
panel header text, and the "Input XML" label — all looked clickable but
did nothing.
- Footer logo + brand: wrap in <a href="/"> so it navigates home, the
universal expectation for a site logo.
- "Input" header title: clicking it now toggles the panel collapse, the
same action as the adjacent chevron button (keyboard-accessible too).
- "Input XML" label: clicking focuses the XML editor below it, like a
real <label>.
Verified: none of the three register as dead clicks anymore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
@uiw/react-codemirror's basicSetup statically imports @codemirror/lint,
/autocomplete and /search, so they landed in the critical codemirror
chunk regardless of the runtime basicSetup flags. Since lint and
autocomplete are already loaded on demand via useEditorExtras, they were
being shipped twice — once eager, once deferred.
Replace the @uiw wrapper with a thin EditorView binding (controlled
value with an ExternalChange annotation to avoid onChange echo, plus
compartments for theme / base options / deferred extras) and hand-pick
only the primitives the critical path needs: state, view (lineNumbers,
highlightActiveLine, drawSelection, keymap), commands (history), and
language (syntaxHighlighting, indentOnInput, bracketMatching). lint,
autocomplete and the completions table stay in useEditorExtras.
Critical codemirror chunk 448KB/147KB gz -> 361KB/118KB gz. The
autocomplete + lint code moves to deferred chunks, off first paint.
Behaviour verified in a headless browser: typing/onChange, undo/redo,
auto-close tags, deferred autocomplete (65 options), XML lint markers,
and light/dark theme switch (oneDark applied) all work; no page errors.
LCP/FCP/CLS unchanged (already gated by the static skeleton since the
prior commit). Time-to-interactive-editor on throttled mobile improves
from 2111ms to 1821ms median, with far tighter run-to-run variance.
Drops the @uiw/react-codemirror dependency; promotes the four
@codemirror/* primitives to direct dependencies. @monaco-editor/react
stays (only referenced by a test mock).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
After seeding the result pane, LCP was still gated on React mounting:
the largest element stayed a CodeMirror line, which cannot exist until
the bundle has downloaded, parsed and rendered.
Fill the two skeleton panes with the welcome example's actual XSLT and
XML as plain <pre> text. A CSS gradient is not an LCP candidate but text
is, so the largest element on the page now paints with the static HTML
and nothing rendered later exceeds it.
The panes keep their existing box model (border-box holds them at the
same width; only intrinsic height grows with the content), so the
skeleton-to-app transition is unchanged. Measured CLS is identical.
The <pre> blocks are aria-hidden so the shell's aria-live region does
not announce a wall of markup.
Measured over 3 runs, mobile emulation, 4x CPU throttle, 1.6Mbps:
LCP 1988ms -> 624ms median, now equal to FCP. CLS 0.036 unchanged.
Cumulative over both commits: LCP 4064ms -> 624ms.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
The GH_TOKEN secret expired, breaking both workflows: ghcr.io login
failed with "denied: denied" and the Helm chart push failed with
"Authentication failed".
Both jobs only act on this repository, so the ephemeral GITHUB_TOKEN
covers them. publish.yml already declares packages: write and
publish-helm.yml already declares contents: write. Nothing left to
rotate, and no broadly-scoped PAT sitting in Actions secrets.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
LCP was gated on the backend: the largest element on the page is a
CodeMirror line in the *result* pane, which could not exist until the
2s debounce plus the POST /transform round-trip had completed.
- Seed the welcome example's known Saxon output into the result pane on
first visit so it paints on mount. The real transform still runs and
overwrites it; duration/serverMs stay null so no timing is claimed
until the real run lands.
- Drop the static xml-formatter import that was silently defeating the
existing dynamic import (Vite warned about this).
- Load lint, autocompletion, hover docs and the ~50KB completions table
after mount via requestIdleCallback; the editor reconfigures when they
arrive and degrades gracefully if they fail.
Critical index chunk 100KB -> 56KB (28KB -> 17.7KB gzipped);
xsltCompletions split into its own deferred 39.7KB chunk.
Measured over 3 runs, mobile emulation, 4x CPU throttle, 1.6Mbps:
LCP 4064ms -> 2032ms median, CLS 0.033 -> 0.036, FCP unchanged.
Also includes the in-progress timing work: the success box now shows the
client round-trip alongside server-side Saxon time, and Saxon
compilation failures without a code prefix classify as "stylesheet".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
Every failed transformation now emits a single JSON line to stdout
("event":"transform_error") that promtail already ships to Loki:
- class: input_xml (user data) / stylesheet (user XSLT) / backend (bug
candidates) — lets review focus on real defects
- error_code extracted from the Saxon message (XPST0017, FORX0002…)
- xslt_b64 / params_b64: full payloads (capped at 48KB/field)
- repro_url: share-format URL (?xslt=&xml=&version=) — one click opens the
playground with the exact failing case
- New "XSLT Playground — Errors" Grafana dashboard (errors by code, by
class, recent log lines) added to the chart's dashboard ConfigMap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
Devs search literal error messages ("XPST0017", "Content is not allowed in
prolog") with almost no competition. New /xslt/errors/ section mirrors the
function-reference pattern that already ranks:
- 17 error pages: XPST0017/0081/0008, XPTY0004, XPDY0002, XTSE0630/0010,
XTDE1490, XTMM9000, XTRE0540, FORX0002, FODC0002, FORG0001, SXXP0003 and
the three big XML parser messages — each with meaning, causes and
before/after fixes
- Pillar post "XSLT error messages explained" (triage table by error-code
prefix, links all 17)
- Post "XSLT vs DataWeave" targeting the enterprise/MuleSoft audience
- Cross-links: functions hub ↔ errors hub; FirstUpper for category headings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
Function pages ranked at pos 7-9 with 1.2% CTR (6.2k impressions/90d) and
the version pages sat at pos 22-24 with almost no internal links.
- Function/element pages: SERP title now "name — XPath function: syntax &
examples" (one layout change, applies to all 230 pages)
- Reference hub: own title, intro copy, category sections with anchor nav
(was a flat 230-item list ranking at pos 29.7)
- Cross-linking: version badge on every reference page links to its
/xslt-2-0/ / /xslt-3-0/ page (~160 new internal links); version pages
link back to the hub + 6 popular entries each; homepage links the hub
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
Clarity heatmaps showed heavy dead clicks on the (blank) result pane and on
the "edit me!" workspace title.
- Result: add a one-click Copy button and an empty state ("Run a transform —
the output appears here.") instead of a blank read-only editor
- Title: clicking the active workspace name now starts renaming (matches the
"edit me!" hint); double-click also works
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
pickSourceXML always ran html.UnescapeString on the chosen source document,
turning valid "&" into a bare "&" and breaking XML parsing across all
engines (e.g. the default welcome example failed with "The entity name must
immediately follow the '&'"). The same corruption was already fixed for the
parameters path in e2fdbfd but the source path was missed.
Only unescape when the value is HTML-encoded (starts with "<"); leave
already well-formed XML untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
syncParams re-derived param names from the injected stylesheet but only
deduped against params[1..], so the primary input's injected name was added
again as a named param — producing two <xsl:param name="input"> and a Saxon
"Variable is multiply defined" error on the default workspace.
- Skip the primary input's name when syncing named parameters
- Filter out named params colliding with the primary (self-heals state
persisted by the previous build)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
User feedback asked to paste/edit input XML directly. The input was modeled
as named "parameters" in a "Data Pipeline" — each collapsed and requiring a
name, burying the common single-input case.
- Primary "Input XML" (params[0]) is now an always-visible, name-free editor
- Multiple named parameters move to a collapsible secondary "Named parameters"
section; backend pickSourceXML already auto-detects the source document
- Add-param now opens + auto-names; removeParam guards the primary input
- Maintain params[0] invariant across defaults, clear and import
- Update DataPipelineHeader (title "Input") and its test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
- blog: "Best free online XSLT 3.0 testers compared (2026)" — answer-first
comparison post to improve LLM/AI-engine and search visibility
- README: keyword-rich header with live tool link, Saxon HE 3.0
differentiator, feature list and suggested GitHub topics
- frontend: raise EthicalAds auto-refresh throttle from 45s to 60s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
The kube-prometheus-stack dashboard sidecar only watches its own namespace,
so the ConfigMap must live in observability to be auto-provisioned.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The ServiceMonitor selector matched on component=backend/frontend, but that
label only existed on the Service spec.selector, not metadata.labels, so the
prometheus-operator keep relabel dropped all targets. Add it to metadata.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Instrument the Go backend with Prometheus metrics (HTTP request rate/
latency/in-flight and per-version transformation outcomes, Saxon duration,
payload size, trace usage), exposed on a dedicated internal port 9100 so
they are never routed through the public ingress. Add an nginx exporter
sidecar to the frontend. Ship ServiceMonitors (release=kube-prom-stack)
and a Grafana dashboard ConfigMap via the Helm chart.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
maybeRefreshAd only checked a stale adVisibleRef that stays true when the
tab is hidden, so the 30s timer kept firing reload() on background tabs —
generating ad decisions that never count as views and tanking the view
rate of the xsltplayground-main placement. Gate on document.visibilityState
and relax the interval to 45s.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 1 (home): add explicit "viewer", "validate xslt online" and
"xslt transformation online" coverage in copy, meta keywords, a new
section and a FAQ entry (+ JSON-LD) to lift queries stuck at position 8-10.
Phase 2 (landing pages): add unique runnable examples with input/output
(for-each-group on /xslt-2-0/, maps + fold-left on /xslt-3-0/) plus
internal links to related blog posts and pre/code styling.
Phase 3 (blog): xsl-online-tester gets links to both version landing pages,
a FAQ block and related guides; xslt-string-functions gets a snippet-bait
summary table and related links.
Adds scripts/seo_report.py to pull GSC + GA4 metrics for tracking progress.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When an XML parameter value starts with '<', html.UnescapeString was
incorrectly called, converting & → & and breaking well-formed
XML that contained entity references. Only apply HTML decoding when
the value starts with '<' (HTML-encoded XML sent from a form field).
Fixes: 'The entity name must immediately follow the & in entity reference'
error reported when XML input contains & characters escaped as &.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
adsbygoogle.js (28KB) + consent framework (126KB + 32KB) were loading
on every blog page despite inArticleSlot being empty — no ads shown,
186KB of JS downloaded for nothing. Mobile LCP: 11.1s → expected ~2-3s.
Keep the google-adsense-account meta tag for account verification.
Script now conditional on both client AND slot being configured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
XSLT is a professional/enterprise tool. Donation models don't
convert for this audience. Removes the external CDN request and
floating button clutter. EthicalAds remains.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Title: "XSLT Tester & Validator Online — Free, XSLT 1.0/2.0/3.0"
Description: leads with "Run, test and validate" action verbs.
Targets low-CTR queries: xslt tester, xsl validator (pos 6-8, CTR <1%).
2. Sitemap: add lastmod 2026-05-26 to all entries to signal freshness
and accelerate Googlebot crawl of /xslt-2-0/ and /xslt-3-0/
(both 0 impressions — never crawled despite being live for 3 weeks).
3. Blog internal links: update 3 posts to link directly to
/xslt-2-0/ and /xslt-3-0/ landing pages instead of root,
giving Googlebot anchor text and link equity for those URLs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A) Blog: add prominent playground-cta card at the end of each post.
Styled with brand gradient border, eyebrow + headline + CTA button.
Remove the buried related-cta line from related-posts.html.
B) App: load a meaningful XSLT welcome example for first-time visitors
(no localStorage) and auto-run it immediately (autoRunReady=true),
so new organic users see live output instead of a blank editor.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Saxon 9.6 lacks ErrorReporter/XmlProcessingError (introduced in Saxon 10),
so Saxon2Daemon is now self-contained using JAXP ErrorListener. Runner.java
is excluded from the Saxon 9.6 build step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Saxon 9.8+ supports XSLT 3.0, so Saxon 10 had the same validation gap
as Saxon 12. Saxon 9.6 is the last release focused on XSLT 2.0; it does
not process xsl:mode or other XSLT 3.0 declarations, making it a proper
XSLT 2.0 validator. Downloaded from SourceForge at build time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Saxon 9.9.x is not available on Maven Central; Saxon 10.9 is the closest
alternative (last pre-12 major release, stricter 2.0 semantics than 12).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each XSLT version now runs against its own true processor rather than
everything going through Saxon 12 in backwards-compatible mode:
- version=1.0 → port 8082, JDK built-in XSLTC (proper XSLT 1.0 validator)
- version=2.0 → port 8083, Saxon HE 9.9.1.8 (true XSLT 2.0 semantics)
- version=3.0 → port 8081, Saxon HE 12.5 (unchanged)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend no longer defaults to <root/> as source document; when no XML
parameter is detected, source is empty and setInitialContextNode is
skipped, allowing Saxon to invoke xsl:initial-template automatically
- Remove "The only free online XSLT" wording from meta descriptions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The snippet was called with 4 args instead of 5, causing r=clarityId
(createElement with wrong tag) and i=undefined (tag src broken).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reading /?v=2.0 or /?v=3.0 now sets the version on the active workspace
and updates the stylesheet version declaration. URL param is cleaned after
reading. Enables CTAs from /xslt-2-0/ and /xslt-3-0/ landing pages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Revert to pre-5d81b58 pattern that had both working:
- xsltplayground-main: fixedheader, always in DOM, loaded with load(element)
- xsltplayground-params: stickybox, inside params panel, only rendered when
ethicalAdsReady=true so EthicalAds MutationObserver auto-discovers it
No createPortal, no conflict between explicit and auto-discovery loads.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Load stickybox via load(element) first (proven to work), then load
fixedheader separately 300ms later to avoid conflict between the two calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove fixedheader slot, restore single xsltplayground-params stickybox
via createPortal with load(element) + innerHTML clear, exactly as at b5736c5.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Original working implementation used createPortal to document.body and
id=xsltplayground-params. Restore both to match the known-working state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Calling load(element) twice in sequence was silently failing on the second.
No-arg load() auto-discovers all EA elements in the DOM at once.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both slots rendered unconditionally at app root — EA's stickybox is
position:fixed so it doesn't need to live inside the result pane.
Single effect loads both refs when ethicalAdsReady.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The stickybox slot only mounts after the first transform (showResultPane=true).
Split into two effects: header loads on ethicalAdsReady, sticky loads when
showResultPane changes and its ref is available.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- fixedheader: native EthicalAds placement at top of app (above tabs),
full-width sticky bar as designed
- stickybox: new slot in result pane right column (160px), shown whenever
result pane is visible
- Two slots loaded via separate refs; both initialized on ethicalAdsReady
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds google-adsense-account meta for publisher ca-pub-1549720748100858
to both xsltplayground.com and blog.xsltplayground.com.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Let EthicalAds render its own horizontal bar layout via data-ea-style=fixedheader.
CSS only overrides position/z-index to keep it inline instead of viewport-fixed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Apply flex-direction:row + inline-flex on callout regardless of which
nesting level EthicalAds places it at.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>