esen_seo 0.10.1
esen_seo: ^0.10.1 copied to clipboard
Real semantic HTML for Flutter Web — SEO widget extensions, meta tags, OpenGraph, JSON-LD and a bot-aware SSR server. No Puppeteer, pure Dart.
0.10.1 #
- Expanded application runtime builder compatibility through analyzer 14.
Conditional
partsyntax remains rejected by parser diagnostics, without relying on an AST getter removed by newer analyzer releases. CI now verifies both the minimum and latest supported analyzer versions.
0.10.0 #
- Added the first application-authored DOM-first state slice.
SeoTabsaccepts a pureSeoTabsTransition, while a DOM-first route can select the same logic throughSeoDomFirstApplicationRuntime.tabs. Theesen_seo_runtimebuild command parser-checks the application's complete import graph, compiles a CSP-compatible JavaScript artifact and writes a SHA-256 manifest outsidelib/. Middleware and prerendering require a runtime store, which validates identity, bytes, gzip budget and hash before caching the artifact for route-scoped inline delivery; missing, stale, foreign and inconsistent artifacts fail closed. Existing Flutter and non-opted-in route delivery remains unchanged. - Hardened application runtime delivery with fixed raw-script and manifest read limits, successful-verification caching and redirect resolution before runtime loading. Runtime scripts now reject HTML script-tokenizer hazards in addition to script-end tags and string-to-code constructors. The SHA-256 manifest is documented as a consistency check; the build directory remains trusted deployment input.
- Added
SeoThemeToggle, a controlled Flutter light/dark control with a separately selectedSeoDomFirstFeature.themeTogglepresentation. The generated web adapter restores a closedlight/darkpreference before first paint, follows the system when no preference exists, persists changes best-effort and keeps malformed markers inert. Theme CSS opts into explicit palette selectors withenableManualTheme: true; existing stylesheet and document bytes remain unchanged by default.compactOnSmallScreensswitches both Flutter and DOM-first controls to an accessible icon-only presentation at widths up to 600 pixels. - Added
SeoCollection, a complete searchable, single-category-filterable, sortable and paginated content collection. Flutter and the separately opt-inSeoDomFirstFeature.collectionruntime share one prepared pure Dart transition; the DOM-first adapter validates the complete bounded structure before creating controls or changing visibility. Every item and native link remains in the source without JavaScript. Invalid, ambiguous, oversized or single-item collections stay complete static HTML, and sort keys are limited to integers represented exactly on both the Dart VM and JavaScript. - Fixed collection sort stability when the initial sort differs from source order, duplicate query terms and progressive components nested inside hidden tab or collection panels. DOM-first adapters now preserve source identity and can prepare valid descendants without exposing an inactive ancestor. Multiple selected package runtimes now share one script block, keeping the collection and theme-toggle combination inside its fixed gzip budget.
SeoCollection(synchronizeUrl: true)andbuildSeoCollectionNodescan now opt a DOM-first collection into shareable, history-aware query state. Search replaces the current history entry; category, sort and page actions create entries; Back and Forward restore the same pure collection state. Parameters are namespaced by validated interaction id, preserve unrelated URL data and are canonicalized without introducing client-side routing.- Added an opt-in cross-platform motion pilot for
SeoBarChart.SeoMotionPreset.gentledrives Flutter and CSS from the same pure timing model, with a capped entrance stagger and decorative pointer/press emphasis.SeoDomFirstFeature.motionadds the fixed stylesheet without JavaScript; reduced-motion preferences render the final static state immediately. The default preset remainsnone, preserving previous widget behaviour and HTML bytes, and the renderer's tag, attribute and inline-style policies are unchanged. - Added an opt-in DOM-first delivery slice for routes derived entirely from
the pure component layer.
SeoRouteDelivery.domFirstmakes the semantic document the permanent page for humans and crawlers, through both shelf and static prerendering, without loading a Flutter web artifact. Resolver redirects and error documents are final on these routes regardless ofSeoRedirectScope; Flutter-delivered routes retain their previous bytes and behaviour. SeoDomFirstFeature.tabscompiles the package's pure Dart tabs transition into a route-scoped browser runtime behind a separate opt-in. FlutterSeoTabsnow delegates selection to the same transition. The DOM control validates its complete structure before mutation, creates fixed ARIA controls through one apply boundary, supports pointer and Arrow/Home/End navigation, and leaves the complete document readable without JavaScript. Effects, forms and client routing are not part of this slice.- Added a separate
domFirstStylesheetinput to static prerendering, matching the middleware API. Mixed sites can theme permanent HTML routes without changing the stylesheet bytes of Flutter-delivered routes. - Added
SeoStepper, a state-hidden-content bridge with lazy native Flutter bodies and every step present as an ordered semantic HTML list. A validinteractionIdprogressively adds validated step buttons, previous/next controls, live position, roving focus and Arrow/Home/End keyboard navigation; malformed and single-step structures remain complete static content. - Added
SeoRichText, a declarative inline model shared by native FlutterTextSpans and semantic HTML. Strong importance, emphasis, inline code, safe links and line breaks retain their meaning instead of being flattened throughtoPlainText(); unsafe and nested link targets degrade to text. - Added
SeoCarousel, a bridge for virtualizedPageView.buildercontent. Flutter keeps a lazy native page view while the semantic source contains every slide as a headed section. A validinteractionIdprogressively adds validated previous/next controls, live position, LTR/RTL keyboard navigation and complete no-script fallback; autoplay is deliberately excluded. SeoNavMenu(interactionId: ...)now supports the same opt-in progressive enhancement as tabs. The complete nested link tree stays in the source; validated branches gain native disclosure buttons witharia-expanded,aria-controlsand Escape-to-close focus return. Linked parent entries keep a separate navigation link, while unlinked labels become the toggle only after enhancement.- Native
SeoNavMenudisclosure state now uses structural branch identity, so duplicate sibling labels and labels containing separators open independently. Both pure and Flutter navigation walkers stop with a namedStateErrorat the shared maximum depth instead of overflowing the stack. SeoTabs(interactionId: ...)can now opt a visible semantic page into package-owned progressive enhancement. All panels remain in the source; vanilla JavaScript adds validated ARIA tab controls, click handling, roving focus and Arrow/Home/End keyboard navigation. Invisibleinertmirrors and duplicate DOM ids are refused.prerenderSite(enableInteractions: true)delivers the runtime only forSeoRenderMode.visibleShell;SeoPage.visibleFromNodesprovides the same progressive HTML/CSS/JavaScript document without a Flutter bootstrap. Both support an optional CSP nonce on generated style and script tags. Existing rendering stays byte-compatible when interactions are disabled.- Semi-transparent
SeoBarChartandSeoPieChartcolors now preserve their alpha channel in CSS as#rrggbbaa. Opaque colors keep their existing#rrggbboutput. Component and theme CSS now share the same pure ARGB serializer. - The application runtime builder supports analyzer 6 and 7. CI now executes the browser adapter suites and verifies every generated DOM-first runtime, in addition to the VM and static-analysis gates.
0.9.0 #
The theme bridge: the visible shell in your app's design.
seoStylesheetFromTheme #
- Generates the shell stylesheet from your
ThemeData— the full Material 3 color roles as CSS custom properties (--esen-color-*), the type scale (--esen-type-*), and the font family with a system-font fallback chain. The heading chain follows the Material scale (h1←headlineLarge, which lands at exactly the default stylesheet's 2 rem); the dark theme rides along as aprefers-color-schemeblock carrying only the tokens that differ. ThemeDataexists only where Flutter runs;prerenderSiteruns without Flutter. The bridge crosses that gap the same way the route table does:checkOrUpdateSeoThemeCss(inpackage:esen_seo/testing.dart) writes a committedlib/seo_theme.g.dartwith oneconst String— app and prerender script import it, and every ordinaryflutter testrun verifies it against the live theme. Theme changed without regenerating? The build goes red with the exact command to run. Regeneration is only ever explicit (--dart-define=esenSeoUpdate=true) — deliberately no environment-variable switch, which an ambient CI variable could flip into silent write-mode. The generated CSS is a function of the Flutter version — pin dev and CI to the same Flutter, and regenerate after SDK upgrades; the guard's message tells toolchain skew apart from a real theme change. The guard is exported conditionally, sotesting.dartstill compiles underflutter test --platform chromefor parity users — on the web only calling the guard throws. The comparison covers the declared variable name, not just the CSS literal, and the generated file survivesdart formatuntouched.- Every token value passes an allow list before it becomes CSS —
colors must be hex, sizes
rem, font family names[A-Za-z][A-Za-z0-9 -]*(which drops Apple's dot-prefixed platform names and every CSS breakout payload with the same rule). What fails validation is dropped; the element rules carry fallbacks, so the shell degrades to the default look instead of breaking. The shell background is forced opaque — a translucent one would let Flutter's empty boot surface shine through. - Material 2 themes (
useMaterial3: false) mirror what M2 widgets actually paint: the divider comes fromdividerColor, surfaces fromcardColor— the scheme's M3 fallbacks would be quasi-black lines and flat surfaces the app never shows. - Documented deviations:
h2/h3render a step larger than the default stylesheet (Material scale), headings keep the theme's weight (Material 3 headings are regular, not bold),h4–h6are styled for the first time, and paragraphs default tobodyLarge(16 px) rather than Flutter's 14 pxbodyMedium—bodyRole:gives 1:1 parity. The chosen body role is applied completely: size, weight, line height, letter spacing and the font family all come from the same role. Elevation, shapes and ink effects are not mirrored. - The layout skeleton is now a single shared source
(
seo_theme_css.dart) used by bothseoDefaultStylesheetand the bridge — a grid fix in one stylesheet cannot quietly miss the other. As part of the unification the default stylesheet spacesh4–h6like the other headings. prerenderSiteprints one line naming the stylesheet it embeds (default / themed / custom / none) — the guard watches the generated file, this line watches the last link of the chain: a stylesheet that is generated, committed and then never passed.
The mirror follows SPA navigation, and "not shown" means not shown #
- After a
Navigator.push, the mirror kept serving the previous page: the refresh fired during the route transition, and nothing fired again when it settled. URL, title and canonical said/demo; the semantic body still said the home page — stale content with every signal claiming otherwise. Navigation now re-arms a short refresh window at both route-level (SeoRouteObserver, which also covers apps running purely on smart defaults with no.seo()markers at all) and marker-level (everySeoWidgetlistens to its own route's animation). Stated plainly, in the quick start and onEsenSeo.init/SeoRouteObserverboth: the observer is the navigation hook. Nothing in Flutter tells the package about a push, so a pure smart-defaults app without the observer keeps serving the previous page's mirror — exactly as its title and canonical already behaved. That contract is pinned by a test. The window refreshes across the next few frames rather than betting on one — the Navigator applies the visibility flip in an Overlay rebuild whose exact frame is an implementation detail,ModalRoute.animationis a proxy that fires a spuriouscompletedduring the offstage warm-up of a fresh route, and the unchanged-HTML dedup makes the extra walks write-free. - Underneath sat an older gap: the Navigator never puts inactive
routes in
Offstage— the Overlay keeps them mounted and merely skips them in paint. The walk skipped onlyOffstage, so previous pages leaked into the mirror alongside the current one. The walk now descends the way the framework itself defines "onstage" (debugVisitOnstageChildren— plain logic, the same thing the widget inspector relies on), which also stops inactiveIndexedStackchildren from leaking. Deliberate exception: viewports and lazy lists filter by visual visibility there, and scrolled-away content is still page content — scroll machinery keeps the full traversal, pinned by a regression test. Visibility(visible: false, maintainSize: true)hides its child behindOpacity(0)— invisible but onstage, so no traversal can know. The widget itself is checked now, and so is its sliver twinSliverVisibility— the box widget alone was not the class. And explicitly NOT checked:TickerMode(enabled: false)— Flutter defines it as "pause the tickers", nothing more; perfectly visible content sits inside it, so treating it as an offstage signal would empty the mirror for exactly that content.
Breaking #
- Flutter minimum is now
>=3.27.0(was>=3.22.0). The color conversion uses the float channel accessors introduced in 3.27 —.valueis deprecated there, andtoARGB32()does not exist yet.
0.8.0 #
An SEO audit — the package can now tell you whether your site is actually correct, not just render it.
auditSeoRoutes #
- Reads the route table, not built HTML. The package already owns
every URL, title and node, so a broken internal link is just an
hrefthatmatchSeoRoutecannot match. No crawler, no HTML parser dependency, and it runs beforeflutter build webhas done anything. - Runs in a plain
test(), so it needs no new CI job:assertSeoHealthy(await auditSeoRoutes(routes: …, siteBase: …)). - Catches the class of mistake the renderer cannot refuse, because each
one is a legal use of the API — all verified against the real code:
a
noindexpage still listed insitemap.xml; a concrete route shadowed by a:parampattern declared before it, so the page never runs while the sitemap keeps advertising the URL; acanonicalUrlthe URL policy refuses, which leaves the page with no canonical and suppresses the derived one as well; and a schema value JSON cannot encode, which otherwise throws when the page renders. - Plus duplicate titles and descriptions, missing or multiple
<h1>, images with noalt, links with no destination or no anchor text, and hreflang clusters that are not reciprocal or omit the page itself — which Google discards silently. SeoSeverityis the contract:erroris measurably wrong,warningis very likely wrong but legitimate sites exist,infonever fails a build. Suppress any check by id viaSeoAuditPolicy(ignore: {…}).- A resolver that throws becomes a finding instead of aborting the run
— but the report is marked
partialand cross-page checks are then skipped rather than guessed, since "this title is unique" is unprovable with a page missing.
Parity — package:esen_seo/testing.dart #
auditSeoParitycompares the route table against the widget tree a visitor actually sees. The engine alone can only confirm the table agrees with itself; this is the check the whole approach rests on, since serving crawlers a separately built body is defensible only while the two say the same thing. The realistic failure is mundane — a headline renamed in the widget, the route body forgotten — and nothing else in the package can notice it.- Split along the constraint: comparison is pure Dart
(
compareSeoTrees), so the logic is testable without pumping anything; only capturing the app's tree needs Flutter. TheWidgetTesterstays in your test behind apumpcallback, so the package never depends onflutter_test. - Text that reaches crawlers but not the app is an error — that is
cloaking, however accidental. A differing
<h1>is an error. A heading only the app shows is a warning, since an app legitimately shows more. Links are off by default: navigation lives in the Flutter shell, so the route body will never carry it. - Coverage is reported honestly: checking no page at all is an error — a green run that proved nothing — while pages the sample deliberately skipped are named as info, since sampling is the caller's call.
Fixed — errors reported on correct sites #
An error-severity false positive is the worst defect an auditor can have — it teaches a team to switch the check off. Each of these is fixed, with a regression test built from the shape that triggered it:
SeoAuditPolicy(ignore: …)was wired into 3 of 30 checks and into none of the error-severity ones, so the findings a team would most want to silence could not be. Suppression is applied centrally now.link.brokenjudged targets by which URLs happened to be enumerated, so a deep link into a/docs/:pageroute withoutenumeratePathswas an error — while the engine calls that same route a warning saying "its URLs work for visitors". It asksmatchSeoRoutenow, and treats/whitepaper.pdfas the asset it is.- Host comparison was a string prefix, so
https://x.dev.evil.comcounted as the same site. It parses the URI. canonical.unknown-pathdid not strip query or fragment, so a search page canonicalising to itself with?q=…was a "404".- hreflang reciprocity was judged against indexable pages, so a language variant deliberately kept out of the sitemap made its symmetric partner "not reciprocal" — asserting something untrue.
- Parity compared the first
<h1>on each side, which an app shell or a Navigator route left mounted after a push made fail; and it treated punctuation as content, so an added exclamation mark broke the build. - A canonical pointing at a 410, a redirect or a noindex page is now
reported — the flagship case of the whole audit, and it was not
implemented.
robots: 'none'counts as noindex. A pathological node tree reports instead of raisingStackOverflowError.
Fixed — checks that reported nothing, or the wrong thing #
assertSeoHealthy— the test this package recommended,expect(report.describe(), isNot(contains('[error]'))), never failed:describe()marks an error withx. A helper that owns the comparison cannot drift from the format it compares against, so the helper exists now and the docs point at it. It throws with the whole report in the message.- Relative links were skipped entirely. Only
/-rooted hrefs were examined, soaboutand../agbcould point anywhere at all. They are resolved against the page they appear on, the way a browser does, and the finding quotes both the attribute and the URL it means. - A
javascript:href or imagesrcis reported. The renderer strips it, which is correct — and leaves the crawler an<a>that leads nowhere, with nothing anywhere saying why. - Parity missed the sharpest case: an app that renders the server's
headline as a
<p>. All the words are present, so the text check saw nothing missing, and the app has no<h1>, so a guard that only ran when one existed skipped silently. It is an error now. paths: []was a green parity run that proved nothing. Checking no page at all is a failure; sampling some is the caller's call.- Pattern-versus-pattern shadowing.
/blog/:slugfollowed by/blog/:idleaves the second route wholly dead, and neither the duplicate-path check (the strings differ) nor the concrete-path check saw it. - hreflang now asks
matchSeoRoutelike the link check does, so an alternate pointing into an un-enumerated:paramroute is no longer called unserved; reciprocity is only asserted for pages actually resolved. A relative hreflang URL is reported — the renderer emits it happily and Google then ignores it, which is the worst combination. - Schema requirements brought up to date.
Articleno longer needsheadlineper Google's own documentation, so that is a warning, not a build failure.ReviewneedsauthoranditemReviewedbeside the rating;Eventneeds alocationandLocalBusinessanaddress— both of which the factories omit when the caller passes nothing. AProductwith only a name gets no snippet at all: Google needsoffers,revieworaggregateRatingto have something to show.
The audit checks the markup that actually ships #
- The audit now checks the renderer's view of the tree, not the raw
one. The render decision (
HtmlRenderer.effectiveBodyTag) is exposed and the audit walks it: an<img>carrying text really renders as a<span>, an<a>inside an<a>really loses its link, and attribute names are lower-cased before they are written — so a perfectly rendered{'SRC': …, 'ALT': …}no longer produces two errors about attributes the output demonstrably carries. - Parity compares complete token sequences, not a bag of words or independent word pairs. A passage whose words or repeated pairs appeared in separate places passed as delivered even though the passage itself never was. The app's words are concatenated in tree order first, so a sentence split across adjacent spans still matches; scattered fragments do not.
- A same-host URL on another port or scheme is another origin.
https://x.dev:8443/…andhttp://x.dev/…no longer count as internal againstsiteBase: https://x.dev. Uri's default ports settle the port case — and a scheme-relative//x.dev/agbis now checked instead of skipped, whileHTTPS://counts as absolute. - Pattern-versus-pattern shadowing generalised.
/:section/:slugdeclared before/blog/:slugswallows it exactly as completely as an identically shaped pattern, and the same-shape check could not see it. - A truncated walk is a finding (
body.truncated), on both the engine and the parity side — silence read as "checked and clean" about content nobody looked at. The parity text walk shares the same depth ceiling instead of recursing without one. - Schema requirements corrected against Google's documentation.
Organizationhas no required properties at all — the error failed valid markup; the name is a recommendation now.WebSiteneedsurlas well asname. And the checks read inside the properties: aProductoffer without aprice, or anaggregateRatingwithout aratingCount/reviewCount, satisfies every presence check and still yields nothing.
Deployment shapes, encodings and generator fidelity #
- Subpath deployments work now. With
siteBase: 'https://user.github.io/repo'— the GitHub Pages shape the README itself advertises — the audit mapped the package's own derived canonical to/repo/about, a path no route serves, and failed EVERY page. The base's path prefix now maps URLs back into route space, and a same-host URL outside the prefix is another site. - Encoded and decoded spellings are one page.
Uri.pathkeeps percent-escapes, so a route declared/überfailed its own derived canonical (/%C3%BCber). Paths are decoded segment by segment before comparison, so an encoded slash stays within its route parameter. The middleware and route observer also map a deployment's base-path prefix back into route space. - Parity severity is graded. An SSR passage whose words are all
present but not adjacent — a Row of Columns interleaves label and
value in tree order — is a warning, not build-failing cloaking; only
words that are genuinely gone stay an error. U+FFFC (the flattened
form of an inline
WidgetSpan) no longer severs a passage. A tree beyond the renderer's depth limit is a structural error, and parity stops before making content claims about the part it could not walk. - The renderer's view, applied everywhere it was still missing.
JSON-LD payloads no longer contribute phantom headings and text to
the audit (the renderer never renders a script's children);
rawTextcounts as the visible content it is; the URL policy is checked against the RAW attribute value, exactly as the renderer checks it — a trailing newline made the renderer drop anhrefwhile the audit, checking the trimmed value, saw nothing; and an image anywhere inside a link counts as its anchor text when it has a non-emptyalt, not only when it is a direct child. - llms-full.txt now describes the page that ships. The markdown
renderer read the raw tree:
'H2'lost its heading, animgcarrying text advertised an image URL the renderer refuses — and dropped the caption it keeps. It walksHtmlRenderer's effective view now, with the same depth ceiling as the audit. - sitemap.xml applies the URL policy to hreflang alternates. It
was the one output path around the choke point — a
javascript:alternate from CMS data shipped verbatim in the served XML while the head correctly refused it. HtmlRendererrefuses trees deeper than 500 levels with an error that names the likely cause (a self-referential resolver tree) instead of aStackOverflowErrormid-request that names nothing.- New checks, corrected checks.
canonical.chain(A→B→C — Google distrusts chains);hreflang.invalid-code(en_USis the single most common authoring mistake, and Google ignores it silently);hreflang.duplicate-target(de and en on the same URL — the copy-paste slip that orphans a language while the cluster stays formally valid).canonical.non-indexablejudges by the robots signal, not sitemap membership — canonicalising onto a page deliberately kept out of sitemap.xml is legitimate. Duplicate titles match case- and whitespace-insensitively. hreflang codes are checked against the actual language, script and region registries instead of only their shape. A regularProductOffer needs a usable price; missing currency is a warning for product snippets, while anAggregateOfferrequireslowPriceandpriceCurrency. AnaggregateRatingin a one-element list is checked like one passed directly. - Fewer wrong findings elsewhere. An
additionalPathsplaceholder (documented: content served outside the table) is no longer nagged for a title it cannot have; a duplicate route pattern is one finding, not duplicate-path plus shadowed; a pattern with an empty segment is not "shadowed" by a:paramthat provably never matches it; a fully walked tree of exactly maxDepth+1 levels no longer claims its own findings were incomplete.
Server hardening #
Four of these change published behavior — read the breaking notes before upgrading.
- Breaking:
seoRedirectMiddleware(forceHttps:)no longer readsx-forwarded-protoby default. The header is caller-controlled on any server reachable directly, so trusting it unconditionally let a request pick its own redirect scheme. Behind a reverse proxy — where the backend sees plain http — settrustProxy: truewhen upgrading, orforceHttpswill loop: the proxy keeps fetching over http and every fetch redirects. - Breaking: entries in
seoRedirectMiddleware(redirects:)are validated at construction. A target that is neither an http(s) URL with a host nor an absolute path throws anArgumentErrorimmediately, instead of being emitted into aLocationheader at request time. Path-only redirects now send a relativeLocation, so a request-controlledHostheader is never reflected — and mapped redirects preserve the query string. - Breaking:
no-referrer-when-downgradeis no longer an acceptedreferrerpolicy. It hands the full URL — path and query — to third-party hosts on same-scheme navigation, which is exactly what the allow list exists to prevent; it should never have been on it.SeoMeta.extraMetaentries namedreferrerpass through the same policy now, closing the one remaining way to emit an unsafe global referrer rule. - Breaking:
matchSeoRoutepercent-decodes path segments. A route declared/überis now reachable as/%C3%BCber, and a:paramresolver receives the decoded value (café, ora/bfor an encoded slash) instead of the raw escape sequence. Decoding happens per segment, so%2Fcannot create a route boundary. - A dot in the last path segment no longer marks a URL as a static
asset.
/page.htmlis a page and/releases/v1.2is a slug; the middleware serves bots SSR for both now, and the audit checks links into them. Known page extensions and version-shaped segments decide, one shared rule (looksLikeSeoPagePath) for both consumers. - The hreflang code check validates against a snapshot of the IANA
registry instead of a shape regex — per Google's documentation only
ISO 639-1 languages and ISO 3166-1 Alpha-2 regions are supported,
and Google's own example of an unsupported code,
es-419, now fails the audit exactly as it fails in Search. - Parity requires the SSR passage as one contiguous token sequence in
the app's text, closing the overlapping-pairs gap (
buy now freepassing againstbuy now … now free). The base-path mapping from the audit round now also applies toseoBotMiddlewareandSeoRouteObserver, hash-router fragments (#/docs) are told apart from in-page anchors (/docs#install), andprerenderSiterefuses templates missing<html>/</head>/<body>and route pairs that collide on case-insensitive file systems.
Also #
- CI runs the example app's own test suite, which audits a real grown route table with the released API. It existed but ran nowhere.
tool/check_pure_dart.dartreplaces CI's hand-written file list with an import-graph walk. Three holes in it are closed and proven closed by planting each: apackage:esen_seo/…self-import, a Flutter import inside a conditional-import branch, and a run from the wrong directory reporting a cheerful pass over a graph that was not there. The hand-written list had fallen behind — two files exported bycore.dartwere never in it — and could not see a Flutter import reached indirectly at all. 49 files are covered where 12 were named.
0.7.0 #
The runtime HTTP surface for dynamic routes — the half of 0.6.0's resolver that was deliberately held back.
seoBotMiddleware #
- Resolver redirects reach humans too. A
SeoRedirectfrom a resolver was served only to bots in 0.6.0; now it answers human visitors as well by default (applyResolverRedirects: SeoRedirectScope.all), because a 301 shown only to Googlebot is cloaking..botsOnlykeeps the old behaviour,.offignores resolver redirects (pair it withseoRedirectMiddleware). Error statuses (404/410) stay bot-only regardless — a human keeps the Flutter app, not an SSR stub — and a resolver failure on the human path never becomes a 5xx. SeoDocument.headersare emitted, filtered at a single chokepoint. The names are an allow list — caching validators (cache-control,expires,etag,last-modified,age),x-robots-tag,linkandcontent-language. Nothing else: notset-cookie(session fixation on a page that has no session), notcontent-encoding(claiming gzip over an uncompressed body), not CORS/CSP/HSTS (site-wide posture must not be decided per page by content), and not the protocol headers the package and the adapter own. Names must be valid HTTP tokens and values printable US-ASCII — stricter than "no CR/LF" deliberately: a non-ASCII value cannot split a response, butshelf_iorefuses to encode it and the page never arrives at all. Page content must not be able to take a page down.varyis merged withUser-Agent, never replaced. All of it is covered by tests over a real socket, not just in-memoryResponseobjects — the failure mode here is a hung request, which an in-memory test cannot see.- Infrastructure files are cached with a TTL, classified per detail
level.
sitemap.xmlandllms.txtread only head metadata, so they cache forever when no route is dynamic and none enumerates paths.llms-full.txtalso renders bodies — and a classic route'sbodybuilder may be async and database-backed, so "classic" does not mean "immutable" — so it caches forever only when no route has a body builder at all. Otherwise 15 minutes, overridable viainfrastructureCacheTtl:. One shared future per file also collapses a stampede — twenty concurrent crawler hits trigger one pass, not twenty. A build that throws is evicted at once; a build that degraded (a row failed and was dropped) is served once but not cached, so a transient database blip cannot freeze a partial sitemap in place for the whole TTL.
Not yet #
SeoDocument.headers is honoured by the middleware only; the
prerenderer still ignores it, since a static file has no response
headers. SeoPage.bodyHtml (verbatim HTML past the policy) still has no
representation in SeoDocument — resolve: on the middleware remains
the escape hatch for hand-written HTML.
0.6.0 #
Database-backed routes: one read produces a page's metadata and its body, so the two can never describe different records.
SeoRoute.dynamic #
SeoRoute.dynamic(path:, resolve:)resolves one concrete URL to aSeoResolution— aSeoDocument(meta + body + status + lastModified) or aSeoRedirect. For/products/:slugbacked by a database, title, description, schemas, body and last-modified come from a single record instead of two reads that could disagree.SeoDocument.notFound()/.gone()express a real 404 / 410 from a resolver;SeoRedirectexpresses a 301/302. The redirect target runs through a single chokepoint (finishSeoResolution) that is stricter than the link policy:http/httpsor a relative path only, a real redirect status only, and never an empty or fragment-only target (both of which redirect to themselves). So a resolver can never emit ajavascript:Location, amailto:one, or split a response — a bad target becomes a 404.SeoRoute(enumeratePaths:)lists the concrete URLs a:paramroute stands for, so the sitemap, llms.txt and the prerenderer can cover a database of pages without hand-listing every slug.resolveSeoPages(...)resolves the whole table once (bounded concurrency, per-page error policy); the prerenderer, the middleware and both llms generators share that single pass, so a URL is read once no matter how many outputs it feeds.- The classic
SeoRoute(meta:, body:)is unchanged and now sugar over a resolver — one internal content path. Existing route tables, the example app, the SSR server andprerenderSiteneed no edits.
Fixed by the same change #
llms-full.txtread a page's metadata and its body separately (two reads of one record in one file); they now come from a single resolution, so the title and body in the output always match.- A resolver-issued redirect or a non-200 is dropped from sitemap.xml
and llms.txt and skipped by the prerenderer (reported via
onSkipped) — a static host can't emit a 301 or a 410 body with a 200 status. - A per-record
lastModifiednow drives<lastmod>, and a record can pull an unpublished page out of the sitemap even when the route opts in.
Breaking #
SeoRoute.metais nowSeoMetaBuilder?(was non-nullable) and deprecated — it isnullforSeoRoute.dynamic. Constructing a route is unchanged; only reading the field off a route breaks. UsematchSeoRoute(...).resolveSync()?.metaOrNull.SeoRouteMatch.buildMeta()/buildBody()are deprecated (removed in 1.0);buildMeta()throws for a dynamic route.seoSitemapXml,seoLlmsTxtandseoLlmsFullTxtnow take eitherroutes:or a pre-resolvedpages:; the sync two throw aStateErrornaming the fix when aroutes:table contains a dynamic route rather than silently emitting an incomplete sitemap. Return types are unchanged. BothseoBotMiddlewareandprerenderSiteresolve internally, so this is unreachable from ordinary use.
Not yet — coming in 0.7.0 #
Resolver redirects apply to bots today; extending them to human visitors
(the anti-cloaking default), emitting SeoDocument.headers, and TTL
caching of a dynamic table's infrastructure files are the runtime HTTP
surface, kept separate from this release.
0.5.1 #
Three findings from an audit that finished after 0.5.0 went out. Two are defects in the visible shell; one is a claim in 0.5.0's own documentation that does not hold.
- The invisible mirror could paint.
width:0;height:0empties a box's content area, but padding, borders and shadows are drawn outside it — andseoDefaultStylesheetsupplies them, since its#esen-seo-contentrule setspadding:2rem 1.25rem;background:#fff. The result was a white ~40×64 px rectangle in the top-left corner: permanently inseoOnlywhenever a stylesheet was passed, and on every visible shell after the handoff, which restores that same inline style while the stylesheet stays in the head. The container's geometry is now pinned inline — padding, border, outline, shadow and the max/min box — so no author rule can give it a surface. visibleShellrequiresEsenSeo.init(), and said so nowhere. The handoff runs on the first mirror refresh, which onlyinit()or a mounting.seo()widget schedules. An app doing neither kept the shell over a Flutter app that had long since painted. There is deliberately still no timeout: a shell that stays put is correct when the engine never arrives, and the package cannot tell that case from a forgotten call. Now documented as a requirement in the README, onSeoRenderMode.visibleShelland onprerenderSite.- A corrected claim. 0.5.0 justified the CSS property allow list
with "an element in the mirror cannot be lifted out of the flow to
cover the page". The first half is right — nothing can escape the
container's clip. The second is not: inside the visible shell a later
sibling with
margin-top:-100vhcovers an earlier one, leaving the real headline visible while its clicks go elsewhere, andbox-shadow:0 0 0 100vmaxrepaints the viewport from a 1 px box. Neither is patchable —marginandbox-shadoware what documents are made of. The allow list keeps content inside the container; it does not police what that content does to itself. The README and the policy's own comment now say that instead.
0.5.0 #
Security hardening, eleven library widgets, and one contract behind them.
Security — please update #
Three audit rounds under a "the content comes from a CMS" threat model
found that the tag and attribute policy ran only on the Flutter path.
SeoPage, seoBotMiddleware and prerenderSite reached HTML without
it, so a SeoNode assembled from untrusted data could put a live
<script> or an onerror= handler into prerendered pages served to
every visitor. The same node was harmless inside the app — safety
depended on which path the data took.
- The policy now lives in
HtmlRenderer, which every path ends in. Tag names and attribute names are validated (both were written verbatim before, so a quote in an attribute name broke out of the attribute list), a refused element loses its attributes along with its identity, andSeoMetarenders through an explicit head mode. - Tags are an allow list of content elements. A block list kept
losing:
<plaintext>,<xmp>and<noembed>swallow the rest of the document,<form>invites credential harvesting, SVG's<animate>rewrites a link into a script URL. Breaking: custom elements and tags outside the list now fall back tospan/div. - URL attributes are an allow list of schemes (
http,https,mailto,tel,sms,ftp, plus relative URLs) and refuse control characters — a tab insidejava<TAB>script:passed the old prefix check and browsers reassembled it.srcsetandpingare checked candidate by candidate;ping,background,longdescand friends count as URLs now. SeoNode.rawTextis escaped as content everywhere except a JSON-LD payload, where every<becomes its JSON escape.styleis an allow list of properties — text, box, colour, flexbox and grid — and positioning is not among them. Patchingpositionvalue by value kept losing:fixed, thensticky, then-webkit-sticky, thenvar(--x)indirection, and finallyposition:relative;top:-100vh;height:200vh;z-index:2147483647, which covers the page from an allowed value. InvisibleShellthat container is a full-viewport clickable overlay in front of the booting app, so the whole class had to go rather than each instance. CSS comments and escapes no longer hide a property name either.- Media may not
autoplay, andreferrerpolicymay not be set to a value that hands the full URL to a third-party host. - The README now states where this guarantee ends. The policy makes
content non-executable; it does not make it honest. In
visibleShellan empty<a>sizedwidth:100vw;height:100vhpaints nothing and still takes the click — two properties every document needs — and aclassvalue reaches whatever your own stylesheet declares for that name,positionincluded. No property list closes that; vetting untrusted content before showing it is the application's call. In the defaultseoOnlymode it cannot arise: the mirror is clipped to zero size,pointer-events:noneandinert, and neither property can be set from inside. - The mirror stays a well-formed tree: a nested
<a>becomes aspan(the parser would otherwise empty the surrounding link), and void elements or empty tags carrying content keep it instead of dropping it silently. - An image
alttext was the one text path intollms-full.txtthat skipped the single-line collapse, so a blank line broke out of the![…]label and wrote its own headings into the file AI assistants read as the structure of the page. - Content may no longer name the hooks the injector steers by — the id
esen-seo-contentand thedata-esen-seomarkers. Nothing was exploitable, but the reason was document order rather than a rule. autofocus,contenteditable,accesskeyandtabindexjoinautoplayon the refused list: the mirror is there to be read. And the visible shell becomesinertwhen its fade starts, not when it ends — the mouse was already held off for those 150 ms, the keyboard was not.- One XML-forbidden character in a route path no longer makes the whole
sitemap.xmlunparseable, and slugs containing?or#are refused rather than written to a file no URL can reach. A route path may also not be a prefix of a generated file, since/robots.txt/xwould create a directory whererobots.txthas to go — and not contain a segment namedindex.html, which is the file every page is written to, so/aand/a/index.html/bwould fight over the same name and the build would die on whichever came second. The IndexNow key reserves its file name the same way, and is validated before the first page is written rather than after the last — an invalid key used to abort the run half-way, leaving new pages beside an old sitemap. An empty path segment is refused too:/a//bwrote its file asa/bwhile the sitemap advertised/a//b, so a real/a/broute replaced it without a word. - Bot responses (and the app responses beside them) carry
Vary: User-Agent. Without it a CDN caches whichever variant it saw first and serves the bot HTML to visitors, or the empty Flutter shell to Google. An existingVaryfrom the wrapped handler is merged, not overwritten — otherwise the fix would cost the app itsAccept-Encodingvariant. - A route with no
bodybuilder no longer answers bots with an empty 200 page — an empty page indexes worse than the app itself. It falls through to the app, not into the 404 branch: the route exists, it just has nothing to mirror, and a 404 would be far worse than either. Google-InspectionTool,GoogleOtherandStoreBot-Googleare recognized, so URL Inspection and the Rich Results Test see the server-rendered version.SeoPage.fromNodesis the documented way to build a page; thebodyHtmlconstructor is the deliberate exception and says so.
Fixed #
SeoNavMenunow shows every declared level on screen. The mirror was already arbitrarily deep, so grandchildren existed in the HTML but could not be reached in the app; the open set is keyed by label path, so same-named entries at different depths stay independent.SeoBlockskips building its HTML on platforms without a mirror. ASeoListViewwas translating every entry on every build on mobile, and throwing the result away.- Prerendering refuses route paths that would escape the build
directory or overwrite
robots.txt,sitemap.xmland the other generated files; the IndexNow key must be a plain file name. - A forged
x-forwarded-protono longer decides the redirect scheme or crashes the request; the invisible mirror isinert, so its links are out of the keyboard tab order;/llms-full.txtcaches its future, so parallel first requests render the site once.
Widget library #
Widgets for content the mirror cannot see, because Flutter never builds it or paints it instead:
SeoNavMenu— the whole menu tree, including closed submenus.SeoListView— every entry of a lazily built list, not the visible handful.SeoTabs— all panels, each behind its own heading.SeoFaq— answers in the source while collapsed, plusSeoFaq.schemaForfor the FAQ rich result.SeoBarChart,SeoPieChart— painted charts as CSS plus a data table.SeoBreadcrumbs(withschemaFor),SeoDataTable,SeoRating,SeoFigure,SeoTestimonial..seoNodes()lets any widget declare its own HTML translation, andSeoBlock/SeoBlockStateis the contract the library follows: one data model, a Flutter presentation and an HTML one.SeoSchema.breadcrumbsaccepts a URL-less final entry, which is what search engines expect for the current page.
0.4.0 #
The prerendered page becomes the first frame.
- New
SeoRenderMode.visibleShellforprerenderSite(renderMode: …): the prerendered semantic HTML is no longer only an invisible mirror but a visible, styled page — readable before the Flutter engine has even started loading. The shell covers the engine's boot phase, fades out (150 ms) once the first frame is rendered, and then falls back to being the invisible crawler mirror. If the engine never loads, users simply keep a readable page. The default mode is unchanged. - CSS delivery for the shell:
prerenderSite(stylesheet: …)inlines critical CSS into the<head>of every prerendered file (with safe</style>escaping), andseoDefaultStylesheetships a ~1 KB classless baseline with dark-mode support.classandstylepass through.seo()attributes as styling hooks. prerenderSitenow refuses to run on an already prerenderedindex.htmlinstead of silently duplicating the canonical link, JSON-LD blocks and content container on a second run.- Fixes: duplicate sitemap.xml/llms.txt entries when
additionalPathsoverlap routes;submitIndexNowgained atimeout(default 10 s) and rejects asiteBasewithout scheme instead of submitting an empty host; markdown special characters in titles, link labels and image alt texts no longer break llms.txt links; the first DOM injection also runs for apps that mirror no widgets (required for the shell handoff).
0.3.0 #
Richer structured data, a smarter sitemap, and first-class support for AI crawlers and instant indexing.
- New Schema.org builders:
SeoSchema.review,SeoSchema.event,SeoSchema.localBusiness— andSeoSchema.productnow takesratingValue/ratingCount/reviewCountforAggregateRatingstars. SeoMeta.extraMeta: arbitrary<meta name="…" content="…">tags (site verification, theme-color, …) without leaving the typed API.- Sitemap upgrades:
SeoRoute(lastModified: …)renders a<lastmod>date, and routes withSeoMeta.alternatesget<xhtml:link rel="alternate" hreflang="…">entries per URL —additionalPathsinherit both from their matching:paramroute. llms.txtandllms-full.txt(https://llmstxt.org) generated from the route table viaseoLlmsTxt(...)/seoLlmsFullTxt(...)— served automatically byseoBotMiddlewareand written byprerenderSite. The full variant inlines each route's server-side body as markdown, so AI assistants read the whole site in one request.prerenderSitewrites a404.html— Firebase Hosting, GitHub Pages & Co. serve it with a real 404 status for unknown paths, closing the SPA soft-404 gap on static hosting too.- IndexNow support:
submitIndexNow(...)pings search engines about changed URLs; the required key file/<key>.txtis served byseoBotMiddleware(indexNowKey: …)and written byprerenderSite(indexNowKey: …).
0.2.0 #
Router-package support.
SeoRouteObservernow works with go_router, beamer, auto_route and any other Router-based package — no extra dependency: routes with URL-like names are matched directly, everything else falls back to the browser URL (path or hash strategy) after the frame.- New
locationProviderparameter onSeoRouteObserverto override where the current location is read from (tests, custom routers). - Integration-tested against go_router.
0.1.0 #
Meta tags, JSON-LD, typed tags and the SSR server.
EsenSeo.setMeta(SeoMeta(...)): title, description, keywords, robots, canonical URL, OpenGraph and Twitter Cards with smart fallbacks (og:title←title, …). Injected head tags replace static index.html duplicates and surviveMaterialApp's title handling.- Schema.org JSON-LD via
SeoMeta.schemas: typed builders (SeoSchema.article/organization/website/product/breadcrumbs/faq) plus a generic constructor for any type; XSS-safe<escaping. - Typed tags:
SeoTextTag/SeoContainerTag(extension types overString) with IDE autocomplete — the tag is now a positional parameter:.seo(SeoTextTag.h1). Shorthand getters for the most common tags:.h1–.h6,.p,.li,.ul,.section,.tr, …. - Tag policy: every standard HTML tag is allowed; tags that execute code
or break the document (
script,style,iframe, head-only tags, invalid names) safely fall back tospan/div. - URL routing:
SeoRoutetable as single source of truth (package:esen_seo/core.dart, pure Dart, shared between app and server) with:parampath matching.SeoRouteObserverapplies meta tags automatically on navigation;seoBotMiddleware(routes: ...)renders the same pages for bots and serves generatedsitemap.xml,robots.txt, derived canonical URLs and real HTTP 404s for unknown paths.EsenSeo.init(cleanUrls: true)enables path URLs. - hreflang / i18n:
SeoMeta.alternatesrenders<link rel="alternate" hreflang="…">tags for language variants. - Redirects:
seoRedirectMiddleware— canonical host (www → apex), https enforcement (proxy-aware), trailing-slash normalization and exact 301 mappings for relaunches. - HTML attributes: every
.seo()accepts an attribute map (Text(...).seo(SeoTextTag.time, {'datetime': ...})), images getwidth/height/lazy(falling back to the widget's dimensions), links getrel/hreflang. An attribute policy drops event handlers,javascript:URLs and invalid names. - Static prerendering:
prerenderSite()bakes the route table into the web build as per-routeindex.htmlfiles (meta + JSON-LD in the head, semantic body in the hidden container, hydration-safe) plussitemap.xml/robots.txt— full SEO on static hosting without a Dart server. - SSR server (
package:esen_seo/server.dart, pure Dart):BotDetector(search engines, social previews, AI crawlers),SeoPagedocument rendering andseoBotMiddlewarefor shelf — bots get semantic HTML in the page source, users get the Flutter app. - Complete HTML5 void-element list; web build fix for
package:webinterop tear-offs. - Minimum SDK raised to Dart 3.4 / Flutter 3.22 (extension types).
- License: Apache-2.0.
0.0.1 #
Initial release — core SEO pipeline.
.seo()extensions forText,Image,Column,RowandGestureDetector(rendered as<h1>/<p>,<img>,<div>, flex<div>and<a href>).- Semantic HTML is mirrored into the DOM via
package:web— no Puppeteer, no headless Chrome, pure Dart. - Smart defaults: pages without
.seo()tags still render — the first text becomes<h1>, following texts<p>, images<img>. SeoMode.safe(default) andSeoMode.strict(warnings for widgets without.seo()).- Widgets stay untouched on non-web platforms.