Skip to content

feat(sveltekit): add @dualmark/sveltekit adapter - #32

Merged
thepushkaraj merged 6 commits into
dodopayments:mainfrom
yashranaway:feat/sveltekit-adapter
Jun 1, 2026
Merged

thepushkaraj merged 6 commits into
dodopayments:mainfrom
yashranaway:feat/sveltekit-adapter

Conversation

@yashranaway

@yashranaway yashranaway commented May 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds @dualmark/sveltekit — a SvelteKit adapter that generates .md endpoints, llms.txt, and ships a handle hook for bot-aware content negotiation. Closes #5.

Changes

  • New packages/sveltekit with a Vite plugin that generates +server.ts markdown endpoints for configured collections, static pages, parameterized routes, and llms.txt.
  • createDualmarkHandle server hook: bot detection + Accept negotiation + Link rel=alternate injection. Composes with SvelteKit's sequence().
  • New examples/sveltekit-blog — scores 125/125 via dualmark verify under vite dev.
  • Vitest covers hook composition, header passthrough, generated route 200 + content-type, refuse-to-overwrite, and stale-file cleanup (16 tests).
  • Conformance workflow runs the new example end-to-end.
  • README + adapter table updated to include SvelteKit.

Type

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change
  • Spec change (requires bump in AEO_SPEC_VERSION)
  • Docs / examples / tooling only

Verification

  • bun run build passes
  • bun run test passes (16/16 in @dualmark/sveltekit)
  • bun run typecheck passes
  • Ran dualmark verify http://localhost:5173/posts/hello against examples/sveltekit-blog under vite dev → 125/125
  • Spec untouched

Changeset

  • Added .changeset/sveltekit-adapter.md (minor bump for @dualmark/sveltekit)

@vercel

vercel Bot commented May 12, 2026

Copy link
Copy Markdown

@yashranaway is attempting to deploy a commit to the Dodo Payments Team on Vercel.

A member of the Team first needs to authorize it.

@aagarwal1012 aagarwal1012 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yashranaway — this is a substantial, high-quality contribution. End-to-end works, tests are thorough, and the example app actually scores 125/125 in my local verify run. A handful of correctness concerns to address before merge.

Verified locally

  • bun install --frozen-lockfile ✓ (lockfile is reproducible despite configVersion: 0 — that's just Bun 1.3.x normalizing the format, not corruption. My earlier worry was wrong.)
  • bun run build ✓ — all 10 turbo tasks succeed including the sveltekit-blog example
  • bun run test ✓ — @dualmark/sveltekit 16/16 pass, no regressions in other packages (329 tests total)
  • bun run typecheck ✓
  • E2E verify on examples/sveltekit-blog:
    $ vite dev --port 5173 &
    $ node packages/cli/dist/cli.js verify http://127.0.0.1:5173/posts/hello
    Score: 125/125  ✓
    
    All 14 checks pass: md.fetch, md.contentType, md.tokensHeader, md.noindex, md.vary, md.body, md.aeoVersion, md.nosniff, html.reachable, html.linkAlternate, html.vary, negotiation.botUa, negotiation.acceptHeader, negotiation.notAcceptable.

PR claim of "125/125 under vite dev" is verified ✅.

🟡 Issues to address before merge

1. converter-registry.ts will drift against PRs #36 and #39

Your packages/sveltekit/src/converter-registry.ts lists the original 12 converters. PR #39 (integration) and PR #36 (status-page) are both adding to the canonical list. Whichever of those merges before this PR will leave SvelteKit users without that converter (unknown built-in converter 'integration' runtime error, etc.).

Action: Either rebase after #39 lands and add the new cases, or coordinate merge order with maintainers. The Astro and Next.js adapters both update their registry switches when a new converter is added — SvelteKit needs to match.

2. plugin.ts writes to disk during Vite configResolved

packages/sveltekit/src/plugin.ts:209-217:

configResolved(config) {
  generateDualmarkRoutes(input, config.root);
}

configResolved runs on every Vite invocation (dev, build, test, even when Vite is loaded for non-SvelteKit purposes like Vitest). Writing files (mkdirSync, writeFileSync, rmSync) inside this hook means:

  • Any vitest run inside the user's project mutates src/routes unexpectedly
  • Read-only CI checkouts will fail
  • The stale-file cleanup deletes generated files whenever any vite-using tool runs with a different config

Action: Move generation to buildStart (and optionally configureServer for HMR/dev). Use a seen flag to avoid regenerating on every HMR cycle. Most generator plugins follow this pattern (e.g. SvelteKit's own sync, fumadocs, astro:content).

3. The GENERATED_MARKER collision risk

packages/sveltekit/src/plugin.ts:7:

const GENERATED_MARKER = "// @generated by @dualmark/sveltekit; do not edit";

A user who manually starts a file with this comment (perhaps copy-pasted from a generated file they wanted to take over) would have their file silently deleted on next config change by removeStaleGeneratedFiles. Pre-1.0 this is probably acceptable, but consider:

  • A unique random marker per project (stored in a .dualmark/ lockfile), OR
  • A magic header that includes a project-specific hash

Not blocking, but worth noting in the README.

4. Catch-all slug edge case is undocumented

packages/sveltekit/src/handlers.ts:106 findCollectionEntry does entry.id === slug — but with slugStrategy: "catch-all", the slug can contain /. If a user has nested IDs like 2024/01/post, they need to make sure their getEntries() returns entries with those exact slash-containing IDs. Worth a sentence in the README about how IDs map to URL slugs.

5. handle.ts:55 skips negotiation for non-GET/HEAD without injecting Link header

function shouldSkip(pathname, ...) {
  if (method !== "GET" && method !== "HEAD") return true;
  ...
}

shouldSkip returns true for POST/PUT/etc., which means those responses also bypass injectMarkdownAlternateLink at the end of handleRequest. Astro/Next.js adapters likely still inject the Link header on non-GET HTML responses since browsers can follow them. Probably an edge case (POST forms returning HTML), but worth a unit test to lock the intent.

🟢 Looks good

  • 16 tests with great coverage: hook composition with sequence(), 406 behavior, Link/Vary header injection, refuse-to-overwrite, stale-file cleanup
  • Config validation is thorough with clear error messages
  • Conformance workflow job (sveltekit-blog) follows the existing nextjs pattern
  • README is concise and accurate
  • Apache-2.0 LICENSE file included in the package

🔴 Earlier concern, retracted

I previously flagged bun.lock removing "configVersion": 1. After verifying with bun install --frozen-lockfile, this is just Bun 1.3.5 normalizing the field to configVersion: 0 — the lockfile is reproducible. Withdrawn.

Required for merge

  1. Coordinate / rebase against #39 (and #36 if it lands first) to include integration/status-page in the converter registry
  2. Move FS generation out of configResolved to buildStart
  3. Add a 1-line note in the README about catch-all slug ↔ entry.id mapping
  4. (Optional) Unit test for non-GET HTML responses to lock the Link-header behavior

Genuinely well-built PR. Happy to re-review after these.

@yashranaway

Copy link
Copy Markdown
Contributor Author

@aagarwal1012 Fixed requested changes

@yashranaway
yashranaway requested a review from aagarwal1012 May 13, 2026 12:01

@thepushkaraj thepushkaraj left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @yashranaway, thanks for this. Two things before merge:

1. Add "integration" to the SvelteKit registry

#39 landed on main. packages/sveltekit/src/converter-registry.ts needs the same import + union entry + case + error-message update that the Astro and Next.js registries already got. Without it, converter: "integration" throws a runtime error in SvelteKit.

2. Rebase against main (don't merge)

Could you please also rebase against main and fix the merge conflicts?

@yashranaway
yashranaway force-pushed the feat/sveltekit-adapter branch from 27bb169 to 6fabdf2 Compare May 20, 2026 17:27
@yashranaway

Copy link
Copy Markdown
Contributor Author

@thepushkaraj Added integration to the SvelteKit registry and solved conflicts

@yashranaway
yashranaway requested a review from thepushkaraj May 20, 2026 17:29
@dodo-squirrels

Copy link
Copy Markdown

Thanks for the SvelteKit adapter — this is a substantial, well-organized PR. Plugin-based route generation, the handle hook, llms.txt support, and a working example are all here. After running:

bun install
bun run build       # full monorepo, ok
bun run test        # 14 successful tasks; 17/17 sveltekit tests pass
bun run typecheck   # all packages pass

Everything is green when built via turbo in the proper dependency order. (If you build sveltekit in isolation against stale packages/converters/dist you'll see a TS2305: Module '@dualmark/converters' has no exported member 'integrationConverter' error — that's a stale build cache issue, not a problem with this PR. The real CI pipeline is fine.)

A few things to address before merge:

Blocking

  1. Package version is 0.1.0, but the rest of @dualmark/* are at 0.5.2 with linked changesets. .changeset/config.json has "linked": [["@dualmark/*"]]. When the next bun run version-packages runs, the linked rule will yank sveltekit from 0.1.0 directly to the next bumped version (e.g. 0.6.0), which is ugly and confusing in release notes. Please set the initial version to 0.5.2 to match the others — the first release will then land at 0.6.0 for all linked packages consistently. Same issue exists in feat(netlify): add @dualmark/netlify adapter #43 (Netlify).

  2. No docs pages added for @dualmark/sveltekit. This is the same gap as PR feat(deno): add @dualmark/deno edge adapter #41 (Deno):

    • Missing apps/docs/content/docs/integrations/sveltekit.mdx
    • Missing apps/docs/content/docs/packages/sveltekit.mdx
    • apps/docs/content/docs/integrations/meta.json not updated
    • apps/docs/content/docs/packages/meta.json not updated

    The README diff updates the marketing copy ("Astro/Next.js/SvelteKit/Cloudflare") but the user-facing dualmark.dev docs site has no integration page for SvelteKit. Please add both pages mirroring integrations/astro.mdx and packages/astro.mdx shapes.

  3. engines.node is >=18.18.0 but the monorepo (root package.json) requires >=22.12.0. bun install will warn or error in strict mode. Please match >=22.12.0. (Same as feat(deno): add @dualmark/deno edge adapter #41.)

Functional concerns

  1. handle.ts short-circuits the bot/markdown path with event.fetch(mdPath, ...) but never sets AEO response headers (X-AEO-Version, X-Robots-Tag: noindex, X-Markdown-Tokens, Cache-Control). Other adapters wrap the markdown response via buildMarkdownHeaders (CF, Netlify, Fastly, Deno) or markdownResponse (Astro, Next.js). The generated SvelteKit route handlers DO use markdownResponse, but fetchMarkdownTwin (line 51-56) just relays the upstream response unchanged. If the markdown twin route already returns those headers (because it uses markdownResponse internally), this might be fine — but please add a test asserting that headers are present on the bot-served response to lock that in. Or, if the upstream doesn't set them, wrap with buildMarkdownHeaders here.

  2. **shouldSkip returns true for .md pathsand.llms.txt** (line 30-31), which is correct — but the Vary: Accept injection (injectMarkdownAlternateLink) is also gated by !shouldSkip` indirectly through the early return path. For markdown twin routes served as HTML wrappers (none right now, but conceivable), this would mean the link header never gets injected. Document or tighten the contract.

  3. generateDualmarkRoutes writes files during Vite's buildStart (line 212). Good — but dev and build both hit this, and SvelteKit's dev server can rebuild many times. Two concerns:

    • The writeFileSync will re-write identical content on every dev rebuild, which can confuse file watchers and may trigger Vite HMR loops.
    • There's no debounce/dedupe. Consider checking current === source before writeFileSync to avoid no-op writes.
  4. Stale-file cleanup uses the GENERATED_MARKER first line. That's the right approach. But if a user runs the plugin with a config that removes a collection, the cleanup will delete the file — fine. However, if the user manually prepends the GENERATED_MARKER comment to a file they wrote, the plugin will silently delete it. Adding a second-line sentinel (// generated-id: <hash of config + route>) would make this much safer for power users. Not blocking, but worth thinking about.

  5. patternToRegex (handlers.ts:65) only supports [name] and [...name]. SvelteKit's actual routing supports matchers like [slug=string] and optional params [[slug]]. The current regex will treat [slug=string] as a literal key with =string suffix, which is wrong. Add a test or document the limitation in the parameterized-routes section of the README.

Nits

  • README reformatting is the same wide-column issue as feat(converters): add status page converter (closes #12) #36, docs: add Use in CI guide (#20) #38, feat(cli): add V1.0 JSON output for verify command #35. Please revert the prettier hunks not related to SvelteKit.
  • .gitignore adds a single line — what is it? (Couldn't see the diff in the file list summary; please confirm it's specific to the new example.)
  • The conformance workflow correctly waits 10 seconds before dualmark verify — but a fixed sleep is flaky on slower runners. A small "wait until 200" curl loop would be more robust.
  • LICENSE file is included ✓.
  • 16 tests covering hook composition, header passthrough, generated routes, refuse-to-overwrite, and stale-file cleanup — nice.
  • config-validation.ts thoroughly checks siteUrl, route shape, pattern requirements, missing functions. Good error messages with the offending value quoted.

Looks good

  • Plugin generates +server.ts files with prerender hints and entries() for SSG — that's the idiomatic SvelteKit pattern.
  • createDualmarkHandle composes with sequence() (Svelte's hook chaining) cleanly.
  • Both single-segment and rest-segment slugs supported via slugStrategy (line 124).
  • llms.txt handler is a separate generated route, kept simple.
  • Changeset present ✓.
  • Conformance workflow correctly added for the new example.
  • README package table updated to include SvelteKit (line 45+).

Could you push fixes for items 1-3 (blocking)? Items 4-8 would be nice follow-ups but aren't strictly required. Happy to re-review.

Set initial package version to 0.5.2 for linked releases, match monorepo
Node engine requirement, and add dualmark.dev integration/package docs.

Co-authored-by: Cursor <[email protected]>
@yashranaway

Copy link
Copy Markdown
Contributor Author

@thepushkaraj Done

@dodo-squirrels

Copy link
Copy Markdown

Automated review

Verdict: APPROVE (with minor suggestions)

Build/Test Results

  • bun install: pass
  • bun run build --filter=@dualmark/sveltekit: pass (ESM+CJS+DTS in ~1.5s)
  • bun run test --filter=@dualmark/sveltekit: pass — 17/17 tests (4 plugin + 7 handle + 6 handlers)
  • bun run typecheck --filter=@dualmark/sveltekit: pass
  • bun run --filter=dualmark-example-sveltekit-blog dev: started vite dev on port 5174 → SvelteKit picked up the generated +server.ts files for /posts/[slug].md, /posts.md, /index.md, /llms.txt.
  • E2E conformance: dualmark verify http://127.0.0.1:5174/posts/hello → 125/125 Advanced conformance. Every required and recommended check passes including html.vary (Vite's dev server doesn't impose framework Vary headers like Next.js does).
  • dualmark verify --json: all 14 checks pass.

Spec conformance checklist

All Basic / Standard / Advanced checks pass against the live vite dev example. Direct curls:

GET /posts/hello              → 200 text/html + Link: </posts/hello.md>; rel="alternate"; ... + Vary: Accept
GET /posts/hello.md           → 200 text/markdown; charset=utf-8 + X-AEO-Version 1.0 + X-Markdown-Tokens 73 + X-Robots-Tag noindex + X-Content-Type-Options nosniff + Vary: Accept + Cache-Control public, max-age=3600
GET /posts/hello (Accept: md) → 200 text/markdown (via .md twin fetch)
GET /posts/hello (GPTBot)     → 200 text/markdown
GET /index.md                 → 200 text/markdown (static page render)
GET /llms.txt                 → 200 (well-formed llms.txt with brand, description, sections)

Architecture highlights

This is the cleanest adapter in the open-PR set:

  1. Reuses @dualmark/core helpers properly. handle.ts:3 imports injectMarkdownAlternateLink from core. The Cloudflare/Vercel/Deno/Fastly/Netlify adapters all inline the Link+Vary logic; SvelteKit factors it into core. This pattern should propagate to the others as a follow-up refactor.

  2. Vite plugin generates +server.ts files (plugin.ts:189-201). Uses a // @generated by @dualmark/sveltekit; do not edit marker (plugin.ts:7) and refuses to overwrite user-authored route files (plugin.ts:153-157). On rebuild it cleans up stale generated files (plugin.ts:181-187). Excellent file-system hygiene.

  3. Mirrors @dualmark/astro's collection/staticPage/parameterizedRoute/llmsTxt config shape. Users of either framework can transfer concepts. The createDualmarkHandle SvelteKit Handle parallels Astro's middleware.

  4. handle.ts:65 shouldSkip is more nuanced than the edge adapters: skips non-GET/HEAD, .md paths, appDir-prefixed assets, /favicon.ico, and configurable skipPaths with both exact and prefix-with-slash matching. Better than Cloudflare's substring startsWith check.

  5. Trailing slash handling is delegated to SvelteKit core (configurable via +layout.ts's trailingSlash), so this adapter doesn't reimplement what the framework already does. Good restraint.

Non-blocking suggestions

  1. Branch is out of date. Reverts chore: version packages (#44) — 8 package.json versions roll back from 0.7.0 to 0.5.2/0.6.0, */CHANGELOG.md 0.7.0 sections deleted, bun.lock workspace metadata rolled back, and re-adds the two changesets (add-integration-converter.md, extend-ai-bot-registry.md) that were already consumed. Drop these via git rebase origin/main.

  2. packages/sveltekit/package.json:3 version is "0.5.2" — should start at 0.1.0 (matching @dualmark/vercel PR feat(vercel): add @dualmark/vercel edge adapter (closes #8) #51) or 0.0.0 then have the minor changeset bump to 0.1.0. The current 0.5.2 + minor changeset would result in 0.6.0 which isn't right for a new package.

  3. Three exports for the same value (index.ts:16-18):

    export { dualmarkSvelteKit, dualmarkSvelteKit as dualmark, dualmarkSvelteKit as default, ...}

    dualmarkSvelteKit (specific) + dualmark (generic alias) + default is one too many. The Astro and Next.js adapters use single-named exports. Pick: keep dualmarkSvelteKit named export + default export. Drop the dualmark alias to avoid shadowing the brand name.

  4. handle.ts:53 fetchMarkdownTwin uses event.fetch(mdPath, { headers: event.request.headers }) which is SvelteKit's scoped fetch — it'll re-enter the dev server, then re-enter handle, then fetch the .md route. Confirmed not an infinite loop because shouldSkip short-circuits on .md paths (handle.ts:31). Worth a defensive code comment explaining the re-entry pattern.

  5. hooks / analytics consistency. The PR doesn't appear to expose an onAIRequest / onMiss hook surface. Other adapters (Cloudflare, Vercel, Deno, Fastly, Netlify) all do. Either add equivalents (and align naming across all adapters) or document that SvelteKit users should wrap event.fetch themselves for analytics.

  6. Tests cover the happy paths well (17 total) but missing:

    • Streaming preservation through the resolve-then-inject-Link path.
    • Property-based test that arbitrary upstream Vary headers don't lose Accept after injection.
    • 406 path for Accept: image/png with no UA bot.
      Add a few more in a follow-up.
  7. packages/sveltekit/README.md (98 lines) — solid. Add an explicit "Score against the spec" section showing the dualmark verify output for a built example, like cloudflare's README does.

  8. .github/workflows/conformance.yml (+18 lines) — please post the diff in the PR description so reviewers can see the new SvelteKit conformance job. Make sure it does both vite dev AND vite build && vite preview — the .md route generation runs at buildStart, but production behavior under vite preview is what end-users see.

  9. apps/docs/content/docs/integrations/sveltekit.mdx (+188 lines) and packages/sveltekit.mdx (+199 lines) — looked thorough at a glance. Please verify the rendered docs site picks them up and the sidebar shows them. The meta.json +1/-1 updates in both integrations/ and packages/ confirm the sidebar entry.

  10. No LICENSE issue — packages/sveltekit/LICENSE (+201 lines, Apache-2.0) matches monorepo policy. ✓

Summary

Best-implemented adapter in the open-PR set: 125/125 spec score, 17 tests, well-factored use of @dualmark/core helpers (the others should adopt this pattern), clean Vite plugin lifecycle, careful generated-file management with overwrite protection. Rebase to drop the v0.7.0 revert, fix the version baseline, simplify the three-way exports, and this is ready to merge. Closes #5.

@yashranaway

Copy link
Copy Markdown
Contributor Author

nothing left according to dodo-squirrels 👀

yashranaway and others added 2 commits May 30, 2026 21:09
Resolve conflicts keeping both SvelteKit and Deno conformance jobs,
sync docs nav, add status-page to the SvelteKit converter registry,
and align package version with linked @dualmark/* releases.

Co-authored-by: Cursor <[email protected]>
The committed bun.lock was generated in bun's hoisted mode (configVersion
absent), so a frozen install hoists adapter dev binaries (astro/next/vite)
to the root node_modules/.bin instead of each example's local .bin. The
Conformance (E2E) jobs launch dev servers via ./node_modules/.bin/<bin>,
which then fails with "No such file or directory" (md.fetch -> 0/20) for
astro-blog, nextjs-app-router and sveltekit-blog.

Rebuilt the lockfile from main's (configVersion: 1, isolated) base so the
per-example bins are restored under bun install --frozen-lockfile.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@thepushkaraj
thepushkaraj dismissed aagarwal1012’s stale review June 1, 2026 18:25

addressed in following commits

@thepushkaraj
thepushkaraj merged commit 08bf7c8 into dodopayments:main Jun 1, 2026
9 checks passed
@yashranaway
yashranaway deleted the feat/sveltekit-adapter branch June 1, 2026 18:45

This branch was successfully deployed

1 active deployment
Preview — af346aec Deployed Jun 1, 2026 by vercel[bot]
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.

Adapter: @dualmark/sveltekit

3 participants