{"language":"crystal","files":[{"path":"README.md","content":"# Roundhouse → crystal\n\nTranspiled from a Rails source app by [Roundhouse](https://rubys.github.io/roundhouse/).\n\nRunning it serves the blog on http://localhost:3000 (set `PORT` to override), with live Turbo Stream updates over the `/cable` WebSocket.\n\n## Prerequisites\n- Crystal 1.10+\n- SQLite (system library)\n\n## Build\n```sh\nshards install\ncrystal build src/main.cr -o server\n```\n\n## Run\n```sh\n./server\n```\n\n## Test\n```sh\ncrystal spec\n```\n\n## End-to-end\nBrowser smoke tests (Playwright). Needs Node.js 18+ and the `sqlite3` CLI; run after the Build steps above — the test config boots the server and seeds `db/seed.sql` itself:\n```sh\ncd e2e\nnpm install\nnpx playwright install chromium\nnpx playwright test\n```\n\n## Regenerate\n```sh\nroundhouse --target crystal -o <output-dir> <input-app>\n```\n"},{"path":"db/seed.sql","content":"-- Seed data for the demo blog, for self-contained reproduction from the\n-- published archive (the archive is text-only, so no binary DB is shipped).\n-- Schema matches config/schema.rb (the binary's Schema.load! is idempotent).\n-- Regenerate from a seeded fixture with scripts/… or sqlite3 .dump if data changes.\n--\n-- This file ships in EVERY target archive (injected by src/project.rs\n-- target_files), so the seed step is language-agnostic — `sqlite3 <db> <\n-- db/seed.sql` populates a fresh DB regardless of which target serves it.\n-- INSERTs name their columns explicitly so the file stays valid even if a\n-- target emits its schema columns in a different order than another.\nCREATE TABLE IF NOT EXISTS articles (id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT, created_at TEXT NOT NULL, title TEXT, updated_at TEXT NOT NULL);\nCREATE TABLE IF NOT EXISTS comments (id INTEGER PRIMARY KEY AUTOINCREMENT, article_id INTEGER NOT NULL, body TEXT, commenter TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL);\nCREATE INDEX IF NOT EXISTS index_comments_on_article_id ON comments (article_id);\nINSERT INTO articles (id, title, body, created_at, updated_at) VALUES (1,'Getting Started with Rails','Rails is a web application framework running on the Ruby programming language. It makes building web apps faster and easier with conventions over configuration.','2026-05-15 21:14:56.300213','2026-05-15 21:14:56.300213');\nINSERT INTO articles (id, title, body, created_at, updated_at) VALUES (2,'Understanding MVC Architecture','MVC stands for Model-View-Controller. Models handle data and business logic, Views display information to users, and Controllers coordinate between them.','2026-05-15 21:14:56.382238','2026-05-15 21:14:56.382238');\nINSERT INTO articles (id, title, body, created_at, updated_at) VALUES (3,'Ruby2JS: Rails Everywhere','Ruby2JS transpiles Ruby to JavaScript, enabling Rails applications to run in browsers, on Node.js, and at the edge. Same code, different runtimes.','2026-05-15 21:14:56.386016','2026-05-15 21:14:56.386016');\nINSERT INTO comments (id, article_id, commenter, body, created_at, updated_at) VALUES (1,1,'Alice','Great introduction! Rails really does make development faster.','2026-05-15 21:14:56.328046','2026-05-15 21:14:56.328046');\nINSERT INTO comments (id, article_id, commenter, body, created_at, updated_at) VALUES (2,1,'Bob','I love how Rails handles database migrations automatically.','2026-05-15 21:14:56.379600','2026-05-15 21:14:56.379600');\nINSERT INTO comments (id, article_id, commenter, body, created_at, updated_at) VALUES (3,2,'Carol','This pattern really helps keep code organized!','2026-05-15 21:14:56.383950','2026-05-15 21:14:56.383950');\n"},{"path":"e2e/action_cable.spec.js","content":"import { test, expect } from '@playwright/test'\n\n// article_3 is seeded with zero comments. Assertions are scoped to *our*\n// uniquely-worded comment so this can run in parallel with the Turbo Stream\n// test, which also posts comments on the same article.\nconst ARTICLE_PATH = '/articles/3'\nconst COMMENTER = 'Cable Bot'\nconst BODY = 'Action Cable broadcast smoke-test comment'\n\ntest('a new comment broadcasts live to other viewers via Action Cable', async ({ browser }) => {\n  // Two independent contexts = two separate viewers of the same article.\n  const observerCtx = await browser.newContext()\n  const actorCtx = await browser.newContext()\n  const observer = await observerCtx.newPage()\n  const actor = await actorCtx.newPage()\n\n  const observerRow = observer.locator('#comments > div').filter({ hasText: BODY })\n  const actorRow = actor.locator('#comments > div').filter({ hasText: BODY })\n\n  try {\n    await observer.goto(ARTICLE_PATH)\n    await actor.goto(ARTICLE_PATH)\n    await expect(observerRow).toHaveCount(0)\n\n    // Wait until the observer's Turbo Stream subscription is confirmed, so the\n    // broadcast can't be missed by a not-yet-connected websocket.\n    await expect(observer.locator('turbo-cable-stream-source')).toHaveAttribute('connected', '')\n\n    // The observer must never navigate; this marker proves the row arrives over\n    // the websocket rather than via a reload.\n    await observer.evaluate(() => { window.__noNav = true })\n\n    // The actor submits a comment.\n    await actor.getByLabel('Commenter').fill(COMMENTER)\n    await actor.getByLabel('Body').fill(BODY)\n    await actor.getByRole('button', { name: 'Add Comment' }).click()\n\n    // The observer receives it live, exactly once, without having navigated.\n    await expect(observerRow).toHaveCount(1)\n    await expect(observerRow).toBeVisible()\n    expect(await observer.evaluate(() => window.__noNav)).toBe(true)\n\n    // Cleanup: delete the comment from the actor page (accept the Turbo confirm).\n    actor.on('dialog', dialog => dialog.accept())\n    await actorRow.getByRole('button', { name: 'Delete' }).click()\n    await expect(actorRow).toHaveCount(0)\n\n    // The removal broadcasts too — the observer's row disappears, leaving no residue.\n    await expect(observerRow).toHaveCount(0)\n  } finally {\n    await observerCtx.close()\n    await actorCtx.close()\n  }\n})\n"},{"path":"e2e/flash.spec.js","content":"import { test, expect } from '@playwright/test'\n\n// Flash shows exactly once, then is swept. Trigger a `redirect_to … notice:`\n// → the redirect target renders the \"successfully updated\" notice → a later\n// navigation must NOT show it again. The SWEEP (second assertion) is the\n// regression that matters: a sticky flash re-renders the notice on every\n// page (the bug that motivated moving the sweep into ActionDispatch::Flash).\n//\n// Why EDIT (not create): the suite runs `fullyParallel` against ONE shared\n// server + DB, and `index.spec.js` pins the seeded set to exactly three\n// articles with specific titles + comment counts. Creating an article would\n// race that exact-count assertion; editing an existing one keeps the count,\n// every title, and every comment count fixed — only the body (which no spec\n// asserts) changes — so flash.spec stays isolated from its neighbours. The\n// sweep is checked by reloading the article's own show page, not the index,\n// to steer clear of index.spec entirely.\n//\n// Scoping (via E2E_SKIP in each target's README ## End-to-end block): runs\n// only on cookie-backed, per-session targets (ruby, jruby, go, rust). The\n// in-memory-flash targets (crystal, typescript) share ONE global flash slot,\n// which races with the comment specs' `redirect_to … notice:` under\n// `fullyParallel`; the remaining targets don't wire flash yet. As a target\n// gains per-session flash, drop it from that skip list.\ntest('flash notice shows once then is swept', async ({ page }) => {\n  await page.goto('/articles/1/edit')\n\n  // Keep the title (index.spec pins it to this exact value); only the body\n  // changes, which no spec asserts. An update with a valid body redirects\n  // with the notice regardless of whether any field actually changed.\n  await page.getByLabel('Title').fill('Getting Started with Rails')\n  await page.getByLabel('Body').fill('Edited by the flash e2e spec to exercise the show-once sweep.')\n  await page.getByRole('button', { name: 'Update Article' }).click()\n\n  // Redirected to the article — the notice renders once.\n  await expect(page.locator('#notice')).toHaveText('Article was successfully updated.')\n\n  // Navigate again — the notice must be gone (swept), not sticky.\n  await page.goto('/articles/1')\n  await expect(page.locator('#notice')).toHaveCount(0)\n})\n"},{"path":"e2e/index.spec.js","content":"import { test, expect } from '@playwright/test'\n\n// The seeded database renders three articles, newest-first (prepend order).\n// Each entry pins the DOM id, the title link text, and the comment-count\n// label shown beside the title.\nconst expectedArticles = [\n  { id: 'article_3', title: 'Ruby2JS: Rails Everywhere',      comments: '(0 comments)' },\n  { id: 'article_2', title: 'Understanding MVC Architecture', comments: '(1 comment)' },\n  { id: 'article_1', title: 'Getting Started with Rails',     comments: '(2 comments)' },\n]\n\ntest('index lists the three seeded articles in order with correct comment counts', async ({ page }) => {\n  await page.goto('/')\n\n  // Exactly three articles render in the list.\n  const rows = page.locator('#articles > div')\n  await expect(rows).toHaveCount(expectedArticles.length)\n\n  // Index positionally so the displayed order is asserted, not just presence.\n  for (let i = 0; i < expectedArticles.length; i++) {\n    const { id, title, comments } = expectedArticles[i]\n    const row = rows.nth(i)\n    await expect(row).toHaveAttribute('id', id)\n    await expect(row.locator('h2 a')).toHaveText(title)\n    await expect(row.locator(`#comments_count_${id}`)).toHaveText(comments)\n  }\n})\n"},{"path":"e2e/package.json","content":"{\n  \"name\": \"app-e2e\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"description\": \"Playwright end-to-end smoke tests for this archive — see ../README.md\",\n  \"scripts\": {\n    \"test\": \"playwright test\"\n  },\n  \"devDependencies\": {\n    \"@playwright/test\": \"^1.49.0\"\n  }\n}\n"},{"path":"e2e/playwright.config.js","content":"import { defineConfig, devices } from '@playwright/test'\n\n// Generated by Roundhouse. Self-contained: `webServer` boots the app\n// (built per ../README.md) and global-setup.js seeds ../db/seed.sql.\n// E2E_SKIP is a space/comma list of spec basenames to skip.\nconst SKIP = (process.env.E2E_SKIP || '').split(/[\\s,]+/).filter(Boolean)\n\nexport default defineConfig({\n  testDir: '.',\n  testIgnore: SKIP.map(name => `**/${name}*.spec.js`),\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  reporter: process.env.CI ? [['github'], ['list']] : 'list',\n  use: {\n    baseURL: 'http://localhost:3000',\n    trace: 'on-first-retry',\n  },\n  // seed.js runs INSIDE the webServer command (not globalSetup —\n  // Playwright boots the webServer first) so the server opens an\n  // already-seeded DB.\n  webServer: {\n    command: 'node e2e/seed.js && ./server',\n    cwd: '..',\n    url: 'http://localhost:3000/articles',\n    reuseExistingServer: !process.env.CI,\n    timeout: 120_000,\n  },\n  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],\n})\n"},{"path":"e2e/seed.js","content":"// Generated by Roundhouse. Seeds the server's DB from ../db/seed.sql\n// (sqlite3 CLI). Runs as the first half of playwright.config.js's\n// webServer command, so the server boots against an already-seeded\n// DB (some targets self-seed demo data on an empty one, with\n// different row timestamps than the canonical seed). Idempotent:\n// skips when articles already exist, so re-runs don't double-seed.\n// For a truly fresh run, delete db/development.sqlite3 (or re-extract the archive).\nimport { execFileSync } from 'node:child_process'\nimport { mkdirSync, readFileSync } from 'node:fs'\nimport path from 'node:path'\nimport { fileURLToPath } from 'node:url'\n\nconst root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')\nconst db = path.join(root, 'db/development.sqlite3')\nconst seed = path.join(root, 'db', 'seed.sql')\n\nmkdirSync(path.dirname(db), { recursive: true })\nlet count = 0\ntry {\n  count = Number(execFileSync('sqlite3', [db, 'SELECT COUNT(*) FROM articles'],\n    { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim())\n} catch { /* missing file or table — seed below */ }\nif (count === 0) {\n  execFileSync('sqlite3', [db], { input: readFileSync(seed, 'utf8') })\n  console.log(`seed.js: seeded ${db} from db/seed.sql`)\n} else {\n  console.log(`seed.js: db already seeded (${count} articles)`)\n}\n"},{"path":"e2e/tailwind.spec.js","content":"import { test, expect } from '@playwright/test'\n\ntest('tailwind is compiled and applied', async ({ page }) => {\n  await page.goto('/')\n\n  // Stronger check: the compiled stylesheet actually serves (catches a broken\n  // asset pipeline directly). Propshaft fingerprints the filename, so derive\n  // the digested href from the page instead of hardcoding it.\n  const hrefs = await page\n    .locator('link[rel=\"stylesheet\"]')\n    .evaluateAll(links => links.map(l => l.getAttribute('href')))\n  const tailwindHref = hrefs.find(h => /tailwind/.test(h)) ?? hrefs[0]\n  expect(tailwindHref, 'expected a stylesheet <link> on the page').toBeTruthy()\n\n  const res = await page.request.get(tailwindHref)\n  expect(res.status()).toBe(200)\n  expect(res.headers()['content-type']).toContain('css')\n\n  // The \"New article\" button (an <a> styled via Tailwind utilities) is the probe.\n  const button = page.getByRole('link', { name: 'New article' })\n  const s = await button.evaluate(el => {\n    const c = getComputedStyle(el)\n    return { display: c.display, background: c.backgroundColor,\n             radius: c.borderRadius, padding: c.paddingTop }\n  })\n\n  // Utilities applied, not browser defaults (an unstyled <a> would be\n  // display:inline, transparent background, no radius, no padding):\n  expect(s.display).toBe('block')                    // `block`\n  expect(s.radius).toBe('6px')                       // `rounded-md`\n  expect(s.padding).toBe('10px')                     // `py-2.5`\n  expect(s.background).not.toBe('rgba(0, 0, 0, 0)')  // not transparent\n\n  // The distinctive color token resolved (bg-blue-600):\n  expect(s.background).toBe('oklch(0.546 0.245 262.881)')\n})\n"},{"path":"e2e/turbo_comment.spec.js","content":"import { test, expect } from '@playwright/test'\n\n// article_3 (\"Ruby2JS: Rails Everywhere\") is seeded with zero comments. The\n// assertions are scoped to *our* uniquely-worded comment (not the whole list)\n// so this test can run in parallel with the Action Cable test, which also\n// posts comments on the same article.\nconst ARTICLE_PATH = '/articles/3'\nconst COMMENTER = 'Playwright Bot'\nconst BODY = 'Turbo stream smoke-test comment'\n\ntest('adding a comment shows the new row via Turbo without a full reload', async ({ page }) => {\n  await page.goto(ARTICLE_PATH)\n\n  const ours = page.locator('#comments > div').filter({ hasText: BODY })\n  await expect(ours).toHaveCount(0) // not present yet\n\n  // Marker on window: a Turbo Drive visit preserves it; a full page reload wipes it.\n  await page.evaluate(() => { window.__noFullReload = true })\n\n  await page.getByLabel('Commenter').fill(COMMENTER)\n  await page.getByLabel('Body').fill(BODY)\n  await page.getByRole('button', { name: 'Add Comment' }).click()\n\n  // Our new comment row appears exactly once (no redirect/broadcast duplicate).\n  await expect(ours).toHaveCount(1)\n  await expect(ours).toBeVisible()\n\n  // Turbo handled the redirect as a Drive visit, not a full browser reload.\n  expect(await page.evaluate(() => window.__noFullReload)).toBe(true)\n\n  // Cleanup: delete the comment we added, accepting the Turbo confirm dialog.\n  page.on('dialog', dialog => dialog.accept())\n  await ours.getByRole('button', { name: 'Delete' }).click()\n  await expect(ours).toHaveCount(0) // gone — no residue left behind\n})\n"},{"path":"e2e/validation.spec.js","content":"import { test, expect } from '@playwright/test'\n\ntest('new article form shows a validation error for a too-short body', async ({ page }) => {\n  await page.goto('/articles/new')\n\n  // Title only needs presence; body must be at least 10 chars. A short body\n  // (9 chars) trips the length validation, so create re-renders :new (422).\n  await page.getByLabel('Title').fill('Hi')\n  await page.getByLabel('Body').fill('too short')\n\n  await page.getByRole('button', { name: 'Create Article' }).click()\n\n  // The error summary appears with the specific failure message.\n  const errors = page.locator('#error_explanation')\n  await expect(errors).toBeVisible()\n  await expect(errors).toContainText('prohibited this article from being saved')\n  await expect(errors).toContainText('Body is too short (minimum is 10 characters)')\n})\n"},{"path":"shard.yml","content":"name: roundhouse-app\nversion: 0.1.0\ncrystal: \">= 1.6.0\"\n\ndependencies:\n  sqlite3:\n    github: crystal-lang/crystal-sqlite3\n"},{"path":"spec/article_spec.cr","content":"require \"../src/test_helper\"\nrequire \"../src/app\"\n\nclass ArticleTest < RoundhouseTest\n  def test_creates_an_article_with_valid_attributes : Nil\n    article = ArticlesFixtures.one\n    raise \"refute_nil failed\" if article.id.not_nil!.nil?\n    raise \"assert_equal failed\" if \"Getting Started with Rails\" != article.title.not_nil!\n  end\n\n  def test_validates_title_presence : Nil\n    article = begin\n      __inst = Article.new({:title => \"\", :body => \"Valid body content here.\"})\n      __inst.title = \"\"\n      __inst.body = \"Valid body content here.\"\n      __inst\n    end\n    raise \"refute failed\" if article.save.not_nil!\n  end\n\n  def test_validates_body_minimum_length : Nil\n    article = begin\n      __inst = Article.new({:title => \"Valid Title\", :body => \"Short\"})\n      __inst.title = \"Valid Title\"\n      __inst.body = \"Short\"\n      __inst\n    end\n    raise \"refute failed\" if article.save.not_nil!\n  end\n\n  def test_destroys_comments_when_article_is_destroyed : Nil\n    article = ArticlesFixtures.one\n    __diff_before = Comment.count.not_nil!\n    article.destroy\n    __diff_after = Comment.count.not_nil!\n    raise \"Comment.count didn't change by -1\" if __diff_after - __diff_before != -1_i64\n  end\nend\n"},{"path":"spec/articles_controller_spec.cr","content":"require \"../src/test_helper\"\nrequire \"../src/app\"\n\nclass ArticlesControllerTest < RoundhouseTest\n  @article : Article?\n\n  def test_should_get_index : Nil\n    @article = ArticlesFixtures.one\n    get(RouteHelpers.articles_path.not_nil!)\n    assert_response(:success)\n    assert_select(\"h1\", \"Articles\")\n    assert_select(\"#articles\") do assert_select(\"h2\", minimum: 1_i64) end\n  end\n\n  def test_should_get_new : Nil\n    @article = ArticlesFixtures.one\n    get(RouteHelpers.new_article_path.not_nil!)\n    assert_response(:success)\n    assert_select(\"form\")\n  end\n\n  def test_should_create_article : Nil\n    @article = ArticlesFixtures.one\n    __diff_before = Article.count.not_nil!\n    post(RouteHelpers.articles_path.not_nil!, params: {article: {body: \"A sufficiently long body for validation.\", title: \"New Title\"}})\n    __diff_after = Article.count.not_nil!\n    raise \"Article.count didn't change by 1\" if __diff_after - __diff_before != 1_i64\n\n    assert_redirected_to(RouteHelpers.article_path(Article.last.not_nil!.id))\n    raise \"assert_equal failed\" if \"New Title\" != Article.last.not_nil!.title\n  end\n\n  def test_should_not_create_article_with_invalid_params : Nil\n    @article = ArticlesFixtures.one\n    __diff_before = Article.count.not_nil!\n    post(RouteHelpers.articles_path.not_nil!, params: {article: {title: \"\", body: \"\"}})\n    __diff_after = Article.count.not_nil!\n    raise \"Article.count didn't change by 0\" if __diff_after - __diff_before != 0_i64\n\n    assert_response(:unprocessable_entity)\n  end\n\n  def test_should_show_article : Nil\n    @article = ArticlesFixtures.one\n    get(RouteHelpers.article_path(@article.not_nil!.id.not_nil!))\n    assert_response(:success)\n    assert_select(\"h1\", @article.not_nil!.title.not_nil!)\n    assert_select(\"h2\", \"Comments\")\n    assert_select(\"#comments .p-4\", minimum: 1_i64)\n  end\n\n  def test_should_get_edit : Nil\n    @article = ArticlesFixtures.one\n    get(RouteHelpers.edit_article_path(@article.not_nil!.id.not_nil!))\n    assert_response(:success)\n    assert_select(\"form\")\n  end\n\n  def test_should_update_article : Nil\n    @article = ArticlesFixtures.one\n    patch(RouteHelpers.article_path(@article.not_nil!.id.not_nil!), params: {article: {body: @article.not_nil!.body.not_nil!, title: \"Updated Title\"}})\n    assert_redirected_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!))\n    @article.not_nil!.reload\n    raise \"assert_equal failed\" if \"Updated Title\" != @article.not_nil!.title.not_nil!\n  end\n\n  def test_should_not_update_article_with_invalid_params : Nil\n    @article = ArticlesFixtures.one\n    patch(RouteHelpers.article_path(@article.not_nil!.id.not_nil!), params: {article: {title: \"\", body: \"\"}})\n    assert_response(:unprocessable_entity)\n  end\n\n  def test_should_destroy_article : Nil\n    @article = ArticlesFixtures.one\n    __diff_before = Article.count.not_nil!\n    delete(RouteHelpers.article_path(@article.not_nil!.id.not_nil!))\n    __diff_after = Article.count.not_nil!\n    raise \"Article.count didn't change by -1\" if __diff_after - __diff_before != -1_i64\n\n    assert_redirected_to(RouteHelpers.articles_path.not_nil!)\n  end\nend\n"},{"path":"spec/comment_spec.cr","content":"require \"../src/test_helper\"\nrequire \"../src/app\"\n\nclass CommentTest < RoundhouseTest\n  def test_creates_a_comment_on_an_article : Nil\n    comment = CommentsFixtures.one\n    raise \"refute_nil failed\" if comment.id.not_nil!.nil?\n    raise \"assert_equal failed\" if ArticlesFixtures.one.id.not_nil! != comment.article_id.not_nil!\n  end\n\n  def test_belongs_to_article_association : Nil\n    article = ArticlesFixtures.one\n    comment = begin\n      __inst = Comment.new({:article_id => article.id.not_nil!, :commenter => \"Commenter\", :body => \"Comment body text.\"})\n      __inst.article_id = article.id.not_nil!\n      __inst.commenter = \"Commenter\"\n      __inst.body = \"Comment body text.\"\n      __inst.save\n      __inst\n    end\n    raise \"assert_equal failed\" if article.id.not_nil! != comment.article_id\n  end\n\n  def test_requires_commenter : Nil\n    article = ArticlesFixtures.one\n    comment = begin\n      __inst = Comment.new({:article_id => article.id.not_nil!, :body => \"Comment without commenter\"})\n      __inst.article_id = article.id.not_nil!\n      __inst.body = \"Comment without commenter\"\n      __inst\n    end\n    raise \"refute failed\" if comment.save.not_nil!\n  end\n\n  def test_requires_body : Nil\n    article = ArticlesFixtures.one\n    comment = begin\n      __inst = Comment.new({:article_id => article.id.not_nil!, :commenter => \"Someone\"})\n      __inst.article_id = article.id.not_nil!\n      __inst.commenter = \"Someone\"\n      __inst\n    end\n    raise \"refute failed\" if comment.save.not_nil!\n  end\n\n  def test_requires_valid_article : Nil\n    comment = begin\n      __inst = Comment.new({:commenter => \"Test\", :body => \"A test comment.\", :article_id => 999999_i64})\n      __inst.commenter = \"Test\"\n      __inst.body = \"A test comment.\"\n      __inst.article_id = 999999_i64\n      __inst\n    end\n    raise \"refute failed\" if comment.save.not_nil!\n  end\nend\n"},{"path":"spec/comments_controller_spec.cr","content":"require \"../src/test_helper\"\nrequire \"../src/app\"\n\nclass CommentsControllerTest < RoundhouseTest\n  @article : Article?\n  @comment : Comment?\n\n  def test_should_create_comment : Nil\n    @article = ArticlesFixtures.one\n    @comment = CommentsFixtures.one\n    __diff_before = Comment.count.not_nil!\n    post(RouteHelpers.article_comments_path(@article.not_nil!.id.not_nil!), params: {comment: {commenter: \"Test\", body: \"A test comment.\"}})\n    __diff_after = Comment.count.not_nil!\n    raise \"Comment.count didn't change by 1\" if __diff_after - __diff_before != 1_i64\n    assert_redirected_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!))\n  end\n\n  def test_should_not_create_comment_with_invalid_params : Nil\n    @article = ArticlesFixtures.one\n    @comment = CommentsFixtures.one\n    __diff_before = Comment.count.not_nil!\n    post(RouteHelpers.article_comments_path(@article.not_nil!.id.not_nil!), params: {comment: {commenter: \"\", body: \"\"}})\n    __diff_after = Comment.count.not_nil!\n    raise \"Comment.count didn't change by 0\" if __diff_after - __diff_before != 0_i64\n    assert_redirected_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!))\n  end\n\n  def test_should_destroy_comment : Nil\n    @article = ArticlesFixtures.one\n    @comment = CommentsFixtures.one\n    __diff_before = Comment.count.not_nil!\n    delete(RouteHelpers.article_comment_path(@article.not_nil!.id.not_nil!, @comment.not_nil!.id.not_nil!))\n    __diff_after = Comment.count.not_nil!\n    raise \"Comment.count didn't change by -1\" if __diff_after - __diff_before != -1_i64\n    assert_redirected_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!))\n  end\nend\n"},{"path":"src/action_controller_base.cr","content":"# Generated from runtime/ruby/action_controller/base.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nSTATUS_CODES = {ok: 200_i64, created: 201_i64, accepted: 202_i64, no_content: 204_i64, moved_permanently: 301_i64, found: 302_i64, see_other: 303_i64, not_modified: 304_i64, bad_request: 400_i64, unauthorized: 401_i64, forbidden: 403_i64, not_found: 404_i64, unprocessable_entity: 422_i64, unprocessable_content: 422_i64, internal_server_error: 500_i64}\n\nmodule ActionController\n  class Base\n    property params : Hash(String, Roundhouse::ParamValue)\n    property session : ActionDispatch::Session\n    property flash : ActionDispatch::Flash\n    property request_method : String?\n    property request_path : String?\n    property request_format : Symbol\n    property status : Int64\n    property body : String\n    property location : String?\n    property content_type : String\n\n    def initialize : Nil\n      @params = {} of String => Roundhouse::ParamValue\n      @session = ::ActionDispatch::Session.new\n      @flash = ::ActionDispatch::Flash.new\n      @status = 200_i64\n      @body = \"\"\n      @location = nil\n      @request_format = :html\n      @content_type = \"text/html; charset=utf-8\"\n    end\n\n    def process_action(_action_name : Symbol) : Nil\n      raise NotImplementedError.new(\"process_action must be overridden by subclass\")\n    end\n\n    def render(body : String, status : Symbol = :ok, content_type : String? = nil, location : String? = nil) : Nil\n      @body = body\n      @status = resolve_status(status)\n      if content_type.nil?\n        nil\n      else\n        @content_type = content_type\n      end\n      if location.nil?\n        nil\n      else\n        @location = location\n      end\n      nil\n    end\n\n    def redirect_to(path : String, notice : String? = nil, alert : String? = nil, status : Symbol = :found) : Nil\n      @location = path\n      @status = resolve_status(status)\n      if notice.nil?\n        nil\n      else\n        @flash[:notice] = notice\n      end\n      if alert.nil?\n        nil\n      else\n        @flash[:alert] = alert\n      end\n      nil\n    end\n\n    def head(status : Symbol, content_type : String? = nil) : Nil\n      @status = resolve_status(status)\n      @body = \"\"\n      if content_type.nil?\n        nil\n      else\n        @content_type = content_type\n      end\n      nil\n    end\n\n    def resolve_status(s : Symbol) : Int64\n      STATUS_CODES.fetch(s, 200_i64)\n    end\n  end\nend\n"},{"path":"src/active_record_base.cr","content":"# Generated from runtime/ruby/active_record/base.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nmodule ActiveRecord\n  class Base\n    property id : Int64\n    @errors : Array(String)\n    @persisted : Bool\n    @destroyed : Bool\n\n    def errors : Array(String)\n      @errors\n    end\n\n    def initialize(_attrs = {} of String => String)\n      @id = 0_i64\n      @errors = [] of String\n      @persisted = false\n      @destroyed = false\n    end\n\n    def self.table_name : String\n      raise NotImplementedError.new(\"#{name}.table_name must be overridden\")\n    end\n\n    def self.schema_columns : Array(Symbol)\n      raise NotImplementedError.new(\"#{name}.schema_columns must be overridden\")\n    end\n\n    def self.instantiate(_row)\n      raise NotImplementedError.new(\"#{name}.instantiate must be overridden\")\n    end\n\n    def self._adapter_find_by_id(id : Int64)\n      row = ::ActiveRecord.adapter.find(table_name.not_nil!, id)\n      return if row.nil?\n      instantiate(row)\n    end\n\n    def self._adapter_all\n      ::ActiveRecord.adapter.all(table_name.not_nil!).map { |row| instantiate(row) }\n    end\n\n    def _adapter_insert : Int64\n    end\n\n    def _adapter_update : Nil\n    end\n\n    def _adapter_delete : Nil\n    end\n\n    def self._adapter_count : Int64\n      ::ActiveRecord.adapter.count(table_name.not_nil!)\n    end\n\n    def self._adapter_exists_by_id?(id : Int64) : Bool\n      ::ActiveRecord.adapter.exists?(table_name.not_nil!, id)\n    end\n\n    def self._adapter_truncate : Nil\n      ::ActiveRecord.adapter.truncate(table_name.not_nil!)\n    end\n\n    def _adapter_reload\n    end\n\n    def attributes\n      {} of String => String\n    end\n\n    def [](_name)\n      raise NotImplementedError.new(\"[] must be overridden by subclass\")\n    end\n\n    def []=(_name, _value)\n      raise NotImplementedError.new(\"[]= must be overridden by subclass\")\n    end\n\n    def assign_from_row(_row)\n    end\n\n    def dom_prefix : String\n      raise NotImplementedError.new(\"dom_prefix must be overridden by subclass\")\n    end\n\n    def persisted? : Bool\n      @persisted\n    end\n\n    def new_record? : Bool\n      !(@persisted)\n    end\n\n    def destroyed? : Bool\n      @destroyed\n    end\n\n    def mark_persisted! : Nil\n      @persisted = true\n      @destroyed = false\n    end\n\n    def self.all\n      _adapter_all\n    end\n\n    def self.find(id : Int64)\n      result = _adapter_find_by_id(id)\n      raise RecordNotFound.new(\"Couldn't find #{name} with id=#{id}\") if result.nil?\n      result\n    end\n\n    def self.find_by(conditions)\n      rows = ::ActiveRecord.adapter.where(table_name.not_nil!, conditions.to_h)\n      return if rows.size == 0_i64\n      instantiate(rows[0_i64])\n    end\n\n    def self.where(conditions)\n      ::ActiveRecord.adapter.where(table_name.not_nil!, conditions.to_h).map { |row| instantiate(row) }\n    end\n\n    def self.count : Int64\n      _adapter_count.not_nil!\n    end\n\n    def self.exists?(id : Int64) : Bool\n      _adapter_exists_by_id?(id)\n    end\n\n    def self.destroy_all\n      records = all\n      records.each { |r| r.destroy }\n      records\n    end\n\n    def self.create(attrs = {} of String => String)\n      instance = new(attrs)\n      instance.save.not_nil!\n      instance\n    end\n\n    def self.create!(attrs = {} of String => String)\n      instance = new(attrs)\n      if instance.save.not_nil!\n        nil\n      else\n        raise RecordInvalid.new(instance)\n      end\n      instance\n    end\n\n    def self.last\n      records = all\n      if records.empty?\n        nil\n      else\n        records[-1_i64]\n      end\n    end\n\n    def save : Bool\n      before_validation\n      ok = valid?.not_nil!\n      after_validation\n      if ok\n        nil\n      else\n        return false\n      end\n\n      before_save\n      if new_record?.not_nil!\n        before_create\n        fill_timestamps(true)\n        @id = _adapter_insert.not_nil!\n        @persisted = true\n        after_create\n        after_create_commit\n      else\n        before_update\n        fill_timestamps(false)\n        _adapter_update\n        after_update\n        after_update_commit\n      end\n      after_save\n      after_save_commit\n      after_commit\n      true\n    end\n\n    def save!\n      if save.not_nil!\n        nil\n      else\n        raise RecordInvalid.new(self)\n      end\n      self\n    end\n\n    def destroy\n      if persisted?.not_nil!\n        nil\n      else\n        return self\n      end\n      before_destroy\n      _adapter_delete\n      @persisted = false\n      @destroyed = true\n      after_destroy\n      after_destroy_commit\n      after_commit\n      self\n    end\n\n    def reload\n      _adapter_reload\n      self\n    end\n\n    def before_validation : Nil\n    end\n\n    def after_validation : Nil\n    end\n\n    def before_save : Nil\n    end\n\n    def after_save : Nil\n    end\n\n    def before_create : Nil\n    end\n\n    def after_create : Nil\n    end\n\n    def before_update : Nil\n    end\n\n    def after_update : Nil\n    end\n\n    def before_destroy : Nil\n    end\n\n    def after_destroy : Nil\n    end\n\n    def after_commit : Nil\n    end\n\n    def after_create_commit : Nil\n    end\n\n    def after_update_commit : Nil\n    end\n\n    def after_destroy_commit : Nil\n    end\n\n    def after_save_commit : Nil\n    end\n\n    def after_touch : Nil\n    end\n\n    def validate : Nil\n    end\n\n    def fill_timestamps(creating : Bool) : Nil\n      cols = self.class.schema_columns\n      now = Time.utc.to_rfc3339.not_nil!\n      self[:updated_at] = now if cols.includes?(:updated_at)\n      self[:created_at] = now if creating && cols.includes?(:created_at)\n    end\n\n    def valid? : Bool\n      @errors = [] of String\n      validate\n      @errors.not_nil!.empty?\n    end\n  end\nend\n\nmodule ActiveRecord\n  property adapter : ActiveRecord::AdapterInterface?\nend\n"},{"path":"src/app.cr","content":"# Generated by Roundhouse — Crystal entry point.\n# Requires the framework runtime in dependency order\n# (Crystal processes `include` at parse time, so modules\n# included by classes must be loaded first), then app code\n# alphabetically.\n\nrequire \"./param_value\"\nrequire \"./inflector\"\nrequire \"./active_record_base\"\nrequire \"./errors\"\nrequire \"./router\"\nrequire \"./action_controller_base\"\nrequire \"./view_helpers\"\nrequire \"./broadcasts\"\nrequire \"./cable\"\nrequire \"./controllers/application_controller\"\nrequire \"./controllers/articles_controller\"\nrequire \"./controllers/comments_controller\"\nrequire \"./db\"\nrequire \"./fixtures/articles\"\nrequire \"./fixtures/comments\"\nrequire \"./flash\"\nrequire \"./http\"\nrequire \"./importmap\"\nrequire \"./json_builder\"\nrequire \"./models/application_record\"\nrequire \"./models/article\"\nrequire \"./models/article_params\"\nrequire \"./models/article_row\"\nrequire \"./models/comment\"\nrequire \"./models/comment_params\"\nrequire \"./models/comment_row\"\nrequire \"./route_helpers\"\nrequire \"./routes\"\nrequire \"./schema\"\nrequire \"./seeds\"\nrequire \"./server\"\nrequire \"./session\"\nrequire \"./test_helper\"\nrequire \"./test_setup\"\nrequire \"./test_support\"\nrequire \"./views/articles/_article\"\nrequire \"./views/articles/_article_json\"\nrequire \"./views/articles/_form\"\nrequire \"./views/articles/edit\"\nrequire \"./views/articles/index\"\nrequire \"./views/articles/index_json\"\nrequire \"./views/articles/new\"\nrequire \"./views/articles/show\"\nrequire \"./views/articles/show_json\"\nrequire \"./views/comments/_comment\"\nrequire \"./views/layouts/application\"\nrequire \"./views/layouts/mailer\"\n"},{"path":"src/broadcasts.cr","content":"# Broadcasts — turbo-stream emit bridge.\n#\n# The model lowerer's `broadcasts_to` expansion produces calls like\n# `Broadcasts.prepend(stream: \"x\", target: \"y\", html: \"...\")` from\n# inside model callback methods (`after_create`, `after_update`,\n# etc.). This shim adapts those calls to the cable broadcaster.\n#\n# State is held in a module-level Array so tests can assert on what\n# was emitted; production also forwards each entry through the\n# installed broadcaster (typically a WebSocket fan-out via Cable).\n\nmodule Broadcasts\n  alias BroadcasterFn = Proc(String, String, Nil)\n\n  @@broadcaster : BroadcasterFn? = nil\n  @@log = [] of NamedTuple(action: String, stream: String, target: String, html: String)\n\n  # Production server installs a broadcaster that pumps fragments\n  # over the cable; tests / CLI runs leave it unset and calls\n  # become silent no-ops (the in-memory log is still populated so\n  # tests can inspect emit ordering).\n  def self.install_broadcaster(fn : BroadcasterFn?) : Nil\n    @@broadcaster = fn\n  end\n\n  def self.reset_log! : Nil\n    @@log.clear\n  end\n\n  def self.log\n    @@log.dup\n  end\n\n  def self.append(*, stream : String, target : String, html : String) : Nil\n    record(\"append\", stream, target, html)\n  end\n\n  def self.prepend(*, stream : String, target : String, html : String) : Nil\n    record(\"prepend\", stream, target, html)\n  end\n\n  def self.replace(*, stream : String, target : String, html : String) : Nil\n    record(\"replace\", stream, target, html)\n  end\n\n  def self.remove(*, stream : String, target : String) : Nil\n    record(\"remove\", stream, target, \"\")\n  end\n\n  private def self.record(action : String, stream : String, target : String, html : String) : Nil\n    @@log << {action: action, stream: stream, target: target, html: html}\n    if fn = @@broadcaster\n      fn.call(stream, render_fragment(action, target, html))\n    end\n    nil\n  end\n\n  # Compose the `<turbo-stream>` fragment. Pure; doesn't touch the\n  # log — used by tests and transport layers that need to ship the\n  # fragment over the wire.\n  def self.render_fragment(action : String, target : String, html : String = \"\") : String\n    if action == \"remove\"\n      %(<turbo-stream action=\"remove\" target=\"#{target}\"></turbo-stream>)\n    else\n      %(<turbo-stream action=\"#{action}\" target=\"#{target}\"><template>#{html}</template></turbo-stream>)\n    end\n  end\nend\n"},{"path":"src/cable.cr","content":"# Roundhouse Crystal cable runtime.\n#\n# Action Cable WebSocket + Turbo Streams broadcaster. Mirrors\n# runtime/rust/cable.rs + runtime/python/cable.py — same wire\n# format (actioncable-v1-json), same partial-renderer registry,\n# same per-channel subscriber map.\n#\n# Uses Crystal's stdlib `HTTP::WebSocket`; no extra shard.\n\nrequire \"base64\"\nrequire \"http/web_socket\"\nrequire \"json\"\n\nmodule Roundhouse\n  module Cable\n    # ── Partial-renderer registry ───────────────────────────────\n\n    @@partial_renderers : Hash(String, Proc(Int64, String)) = {} of String => Proc(Int64, String)\n\n    def self.register_partial(type_name : String, fn : Proc(Int64, String)) : Nil\n      @@partial_renderers[type_name] = fn\n    end\n\n    def self.render_partial(type_name : String, id : Int64) : String\n      fn = @@partial_renderers[type_name]?\n      return fn.call(id) if fn\n      \"<div>#{type_name} ##{id}</div>\"\n    end\n\n    # ── Turbo Streams rendering ─────────────────────────────────\n\n    def self.turbo_stream_html(action : String, target : String, content : String) : String\n      if content.empty?\n        %(<turbo-stream action=\"#{action}\" target=\"#{target}\"></turbo-stream>)\n      else\n        %(<turbo-stream action=\"#{action}\" target=\"#{target}\"><template>#{content}</template></turbo-stream>)\n      end\n    end\n\n    private def self.dom_id_for(table : String, id : Int64) : String\n      singular = table.ends_with?('s') ? table[0, table.size - 1] : table\n      \"#{singular}_#{id}\"\n    end\n\n    # ── Broadcast helpers ───────────────────────────────────────\n\n    def self.broadcast_replace_to(table : String, id : Int64, type_name : String, channel : String, target : String) : Nil\n      t = target.empty? ? dom_id_for(table, id) : target\n      html = render_partial(type_name, id)\n      dispatch(channel, turbo_stream_html(\"replace\", t, html))\n    end\n\n    def self.broadcast_prepend_to(table : String, id : Int64, type_name : String, channel : String, target : String) : Nil\n      t = target.empty? ? table : target\n      html = render_partial(type_name, id)\n      dispatch(channel, turbo_stream_html(\"prepend\", t, html))\n    end\n\n    def self.broadcast_append_to(table : String, id : Int64, type_name : String, channel : String, target : String) : Nil\n      t = target.empty? ? table : target\n      html = render_partial(type_name, id)\n      dispatch(channel, turbo_stream_html(\"append\", t, html))\n    end\n\n    def self.broadcast_remove_to(table : String, id : Int64, channel : String, target : String) : Nil\n      t = target.empty? ? dom_id_for(table, id) : target\n      dispatch(channel, turbo_stream_html(\"remove\", t, \"\"))\n    end\n\n    # ── Subscriber registry + dispatch ──────────────────────────\n\n    # channel name → list of {ws, identifier} pairs. The identifier\n    # is the raw subscribe-frame JSON echoed back on every broadcast\n    # so Turbo routes the frame to the correct stream-source.\n    @@subscribers : Hash(String, Array(Tuple(HTTP::WebSocket, String))) = {} of String => Array(Tuple(HTTP::WebSocket, String))\n    @@subscribers_mutex = Mutex.new\n\n    # `HTTP::WebSocket#send` writes + flushes to the socket and may yield\n    # the fiber mid-write (a blocked write syscall). Two fibers sending\n    # to the SAME socket — e.g. a broadcast from a create-request fiber\n    # racing the per-socket ping fiber, or two near-simultaneous comment\n    # creates both fanning out to a shared subscriber — can then\n    # interleave their bytes and corrupt a frame, so the client silently\n    # drops the broadcast (an intermittent e2e action_cable failure under\n    # parallel load). Serialize all sends through one mutex. go's\n    # coder/websocket and python's asyncio loop get this serialization for\n    # free; Crystal's stdlib socket does not. Global (not per-socket) is\n    # ample for the demo's volume.\n    @@send_mutex = Mutex.new\n\n    private def self.safe_send(ws : HTTP::WebSocket, msg : String) : Nil\n      @@send_mutex.synchronize { ws.send(msg) }\n    rescue\n      # socket may have closed between our snapshot and the write —\n      # cleanup happens on the handler side.\n    end\n\n    private def self.dispatch(channel : String, html : String) : Nil\n      subs = @@subscribers_mutex.synchronize { (@@subscribers[channel]? || ([] of Tuple(HTTP::WebSocket, String))).dup }\n      subs.each do |(ws, identifier)|\n        msg = {\"type\" => \"message\", \"identifier\" => identifier, \"message\" => html}.to_json\n        safe_send(ws, msg)\n      end\n    end\n\n    # ── WebSocket handler ───────────────────────────────────────\n\n    # Route /cable — upgrades the connection, runs the\n    # actioncable-v1-json flow. Called from server.cr.\n    def self.handle(context : HTTP::Server::Context) : Nil\n      # Negotiate the subprotocol — Turbo's client requires it.\n      wanted = context.request.headers[\"Sec-WebSocket-Protocol\"]? || \"\"\n      if !wanted.includes?(\"actioncable-v1-json\")\n        context.response.status_code = 400\n        context.response.print \"unsupported subprotocol\"\n        return\n      end\n      context.response.headers[\"Sec-WebSocket-Protocol\"] = \"actioncable-v1-json\"\n\n      ws_handler = HTTP::WebSocketHandler.new do |ws, _ctx|\n        run_socket(ws)\n      end\n      ws_handler.call(context)\n    end\n\n    private def self.run_socket(ws : HTTP::WebSocket) : Nil\n      sub_entries = [] of Tuple(String, Tuple(HTTP::WebSocket, String))\n\n      safe_send(ws, {\"type\" => \"welcome\"}.to_json)\n\n      # Ping every 3 seconds on a background fiber.\n      ping_fiber = spawn do\n        loop do\n          sleep 3.seconds\n          break if ws.closed?\n          safe_send(ws, {\"type\" => \"ping\", \"message\" => Time.utc.to_unix}.to_json)\n        end\n      end\n\n      ws.on_message do |msg|\n        next unless payload = JSON.parse(msg).as_h?\n        next unless payload[\"command\"]? == \"subscribe\"\n        identifier = payload[\"identifier\"]?.try(&.as_s)\n        next unless identifier\n        channel = decode_channel(identifier)\n        next unless channel\n        entry = {ws, identifier}\n        @@subscribers_mutex.synchronize do\n          (@@subscribers[channel] ||= [] of Tuple(HTTP::WebSocket, String)) << entry\n        end\n        sub_entries << {channel, entry}\n        safe_send(ws, {\"type\" => \"confirm_subscription\", \"identifier\" => identifier}.to_json)\n      rescue\n        # malformed JSON etc — silently drop\n      end\n\n      ws.on_close do |_code, _reason|\n        @@subscribers_mutex.synchronize do\n          sub_entries.each do |(channel, entry)|\n            if list = @@subscribers[channel]?\n              list.delete(entry)\n              @@subscribers.delete(channel) if list.empty?\n            end\n          end\n        end\n      end\n    end\n\n    # Recover the channel name from Turbo's signed_stream_name.\n    # Identifier is a JSON object with\n    #   {\"channel\":\"Turbo::StreamsChannel\",\n    #    \"signed_stream_name\":\"<base64>--<digest>\"}\n    # The base64 prefix decodes to a JSON-encoded channel name.\n    private def self.decode_channel(identifier : String) : String?\n      id_data = JSON.parse(identifier).as_h?\n      return nil unless id_data\n      signed = id_data[\"signed_stream_name\"]?.try(&.as_s)\n      return nil unless signed\n      b64 = signed.split(\"--\", 2).first\n      decoded = Base64.decode_string(b64)\n      JSON.parse(decoded).as_s\n    rescue\n      nil\n    end\n\n    # Stub kept for compatibility with existing server.cr wiring.\n    def self.broadcast(channel : String, body : String) : Nil\n      dispatch(channel, body)\n    end\n  end\nend\n"},{"path":"src/controllers/application_controller.cr","content":"class ApplicationController < ActionController::Base\nend\n"},{"path":"src/controllers/articles_controller.cr","content":"class ArticlesController < ApplicationController\n  @articles : Array(Article)?\n  @article : Article?\n\n  def process_action(action_name : Symbol) : Nil\n    case action_name\n    when :index\n      index\n    when :show\n      show\n    when :new\n      new_action\n    when :edit\n      edit\n    when :create\n      create\n    when :update\n      update\n    when :destroy\n      destroy\n    end\n  end\n\n  def index : Nil\n    stmt = Roundhouse::Db.prepare(\"SELECT id, body, created_at, title, updated_at FROM articles\" + \" ORDER BY created_at DESC\")\n    results = [] of Article\n    while Roundhouse::Db.step?(stmt)\n      results << Article.from_stmt(stmt)\n    end\n    Roundhouse::Db.finalize(stmt)\n    __comments_ids = results.map { |a| a.id.not_nil! }\n    __comments_stmt = Roundhouse::Db.prepare(\"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments WHERE article_id IN (\" + Roundhouse::Db.escape_int_list(__comments_ids) + \")\")\n    __comments_loaded = [] of Comment\n    while Roundhouse::Db.step?(__comments_stmt)\n      __comments_loaded << Comment.from_stmt(__comments_stmt)\n    end\n    Roundhouse::Db.finalize(__comments_stmt)\n    results.each { |a| __comments_group = [] of Comment\n    __comments_loaded.each { |r| __comments_group << r if r.article_id.not_nil! == a.id.not_nil! }\n    a._preload_comments(__comments_group) }\n    @articles = results\n    if request_format.not_nil! == :json\n      render(Views::Articles.index_json(@articles.not_nil!), content_type: \"application/json\")\n    else\n      render(Views::Articles.index(@articles.not_nil!, @flash.not_nil![:notice], @flash.not_nil![:alert]))\n    end\n  end\n\n  def show : Nil\n    @article = Article.find(@params.not_nil!.fetch(\"id\", \"0\").to_s.to_i)\n    if request_format.not_nil! == :json\n      render(Views::Articles.show_json(@article.not_nil!), content_type: \"application/json\")\n    else\n      render(Views::Articles.show(@article.not_nil!, @flash.not_nil![:notice], @flash.not_nil![:alert]))\n    end\n  end\n\n  def new_action : Nil\n    @article = Article.new\n    render(Views::Articles.new(@article.not_nil!, @flash.not_nil![:notice], @flash.not_nil![:alert]))\n  end\n\n  def edit : Nil\n    @article = Article.find(@params.not_nil!.fetch(\"id\", \"0\").to_s.to_i)\n    render(Views::Articles.edit(@article.not_nil!, @flash.not_nil![:notice], @flash.not_nil![:alert]))\n  end\n\n  def create : Nil\n    @article = Article.from_params(article_params)\n    if @article.not_nil!.save.not_nil!\n      if request_format.not_nil! == :json\n        render(Views::Articles.show_json(@article.not_nil!), status: :created, location: RouteHelpers.article_path(@article.not_nil!.id.not_nil!), content_type: \"application/json\")\n      else\n        redirect_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!), notice: \"Article was successfully created.\")\n      end\n    else\n      render(Views::Articles.new(@article.not_nil!, @flash.not_nil![:notice], @flash.not_nil![:alert]), status: :unprocessable_content)\n    end\n  end\n\n  def update : Nil\n    @article = Article.find(@params.not_nil!.fetch(\"id\", \"0\").to_s.to_i)\n    if @article.not_nil!.update(article_params)\n      if request_format.not_nil! == :json\n        render(Views::Articles.show_json(@article.not_nil!), status: :ok, location: RouteHelpers.article_path(@article.not_nil!.id.not_nil!), content_type: \"application/json\")\n      else\n        redirect_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!), notice: \"Article was successfully updated.\", status: :see_other)\n      end\n    else\n      render(Views::Articles.edit(@article.not_nil!, @flash.not_nil![:notice], @flash.not_nil![:alert]), status: :unprocessable_content)\n    end\n  end\n\n  def destroy : Nil\n    @article = Article.find(@params.not_nil!.fetch(\"id\", \"0\").to_s.to_i)\n    @article.not_nil!.destroy\n    if request_format.not_nil! == :json\n      head(:no_content, content_type: \"application/json\")\n    else\n      redirect_to(RouteHelpers.articles_path.not_nil!, notice: \"Article was successfully destroyed.\", status: :see_other)\n    end\n  end\n\n  def article_params : ArticleParams\n    ArticleParams.from_raw(@params.not_nil!)\n  end\nend\n"},{"path":"src/controllers/comments_controller.cr","content":"class CommentsController < ApplicationController\n  @article : Article?\n  @comment : Comment?\n\n  def process_action(action_name : Symbol) : Nil\n    case action_name\n    when :create\n      create\n    when :destroy\n      destroy\n    end\n  end\n\n  def create : Nil\n    @article = Article.find(@params.not_nil!.fetch(\"article_id\", \"0\").to_s.to_i)\n    @comment = Comment.from_params(comment_params)\n    @comment.not_nil!.article_id = @article.not_nil!.id.not_nil!\n    if @comment.not_nil!.save.not_nil!\n      redirect_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!), notice: \"Comment was successfully created.\")\n    else\n      redirect_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!), alert: \"Could not create comment.\")\n    end\n  end\n\n  def destroy : Nil\n    @article = Article.find(@params.not_nil!.fetch(\"article_id\", \"0\").to_s.to_i)\n    @comment = Comment.find(@params.not_nil!.fetch(\"id\", \"0\").to_s.to_i)\n    if @comment.not_nil!.article_id.not_nil! != @article.not_nil!.id.not_nil!\n      head(:not_found)\n      return\n    end\n    @comment.not_nil!.destroy\n    redirect_to(RouteHelpers.article_path(@article.not_nil!.id.not_nil!), notice: \"Comment was successfully deleted.\")\n  end\n\n  def comment_params : CommentParams\n    CommentParams.from_raw(@params.not_nil!)\n  end\nend\n"},{"path":"src/db.cr","content":"# Roundhouse Crystal DB runtime — sqlite primitive layer plus the\n# `ActiveRecord.adapter` plug-in.\n#\n# Three responsibilities:\n#   1. `Roundhouse::Db` — owns the sqlite3 connection. `open_production_db`\n#      is called from `Roundhouse::Server.start`; `setup_test_db` resets\n#      the connection between specs.\n#   2. `Roundhouse::ActiveRecordAdapter` — abstract base pinning the 9-\n#      method contract `runtime/ruby/active_record/base.rb` calls\n#      (`all`, `find`, `where`, `count`, `exists?`, `insert`, `update`,\n#      `delete`, `truncate`). Polymorphic slot so production sqlite,\n#      test in-memory, and future libsql/D1 implementations all plug\n#      into the same `ActiveRecord.adapter` setter.\n#   3. `Roundhouse::SqliteAdapter` — concrete sqlite implementation.\n#      Server boot assigns an instance to `ActiveRecord.adapter`.\n#\n# The `module ActiveRecord ... end` extension at the bottom adds the\n# `.adapter` getter/setter that the Ruby source's\n# `class << self; attr_accessor :adapter; end` would have produced —\n# the runtime_loader transpile pipeline doesn't yet expose\n# module-level attr_accessors on the metaclass, so we declare them\n# here to keep `ActiveRecord.adapter = X` and `ActiveRecord.adapter.X`\n# resolvable.\n\nrequire \"sqlite3\"\n\nmodule Roundhouse\n  module Db\n    @@db : DB::Database? = nil\n\n    # Per-prepared-statement state: the open ResultSet plus the most\n    # recently materialized row. step? advances the cursor and snapshots\n    # the row into `current`; column_int/column_text then index into\n    # the snapshot. crystal-db's ResultSet is sequential-read-only\n    # (`rs.read` consumes one column), so materializing to an array\n    # is the way to keep `column_*(stmt, i)` random-access.\n    class StmtEntry\n      getter result_set : DB::ResultSet\n      property current : Array(DB::Any)?\n\n      def initialize(@result_set : DB::ResultSet)\n        @current = nil\n      end\n    end\n\n    @@statements = {} of Int64 => StmtEntry\n    @@next_id : Int64 = 0_i64\n    @@last_insert_rowid : Int64 = 0_i64\n    @@changes : Int64 = 0_i64\n\n    def self.setup_test_db(schema_sql : String) : Nil\n      reset_statements\n      if old = @@db\n        old.close\n      end\n      db = DB.open(\"sqlite3::memory:\")\n      schema_sql.split(\";\\n\").each do |chunk|\n        stmt = chunk.strip\n        next if stmt.empty?\n        db.exec(stmt)\n      end\n      @@db = db\n    end\n\n    def self.conn : DB::Database\n      @@db.not_nil!\n    end\n\n    def self.open_production_db(path : String, schema_sql : String) : Nil\n      reset_statements\n      if old = @@db\n        old.close\n      end\n      dir = File.dirname(path)\n      Dir.mkdir_p(dir) unless Dir.exists?(dir)\n      db = DB.open(\"sqlite3://#{path}\")\n      count = db.query_one(\n        \"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'\",\n        as: Int64,\n      )\n      if count == 0\n        schema_sql.split(\";\\n\").each do |chunk|\n          stmt = chunk.strip\n          next if stmt.empty?\n          db.exec(stmt)\n        end\n      end\n      @@db = db\n    end\n\n    # ── Low-level prepare/step/column API ────────────────────────\n    #\n    # Mirrors `runtime/spinel/db.rb` and `runtime/typescript/db.ts`\n    # verbatim. Model adapter methods (`_adapter_find`, `_adapter_save`,\n    # etc.) emitted by `src/lower/model_to_library/adapter_emit.rs`\n    # compose inlined SQL via `escape_int`/`escape_string` and dispatch\n    # against this surface. Per-statement state lives in the\n    # `@@statements` table; opaque Int64 stmt ids index into it.\n\n    # Run any one-shot DDL/INSERT/UPDATE/DELETE. Captures the\n    # last_insert_rowid + changes so subsequent calls to those\n    # accessors return the most recent values (the same shape as the\n    # TS shim: `Db.exec(insert_sql)` followed by `Db.last_insert_rowid`).\n    def self.exec(sql : String) : Nil\n      result = conn.exec(sql)\n      @@last_insert_rowid = result.last_insert_id\n      @@changes = result.rows_affected\n    end\n\n    # Prepare a SELECT, returning an opaque integer handle. Subsequent\n    # `step?` / `column_int` / `column_text` / `finalize` calls take it\n    # by reference. Per-process stmt-id sequence; reset across\n    # `setup_test_db` / `open_production_db` so test runs start from 1.\n    def self.prepare(sql : String) : Int64\n      rs = conn.query(sql)\n      @@next_id += 1\n      @@statements[@@next_id] = StmtEntry.new(rs)\n      @@next_id\n    end\n\n    # Advance the cursor on a prepared statement. Returns true and\n    # snapshots the current row into the stmt entry on success; false\n    # (with the snapshot cleared) when the result set is exhausted.\n    def self.step?(stmt_id : Int64) : Bool\n      entry = @@statements[stmt_id]\n      if entry.result_set.move_next\n        col_count = entry.result_set.column_count\n        row = Array(DB::Any).new(col_count) do\n          entry.result_set.read.as(DB::Any)\n        end\n        entry.current = row\n        true\n      else\n        entry.current = nil\n        false\n      end\n    end\n\n    # Read an integer column at zero-based index from the row most\n    # recently snapshotted by `step?`. NULL coerces to 0 (matches the\n    # TS shim and `runtime/spinel/db.rb`); non-Int variants of `DB::Any`\n    # coerce via `to_i64`.\n    def self.column_int(stmt_id : Int64, i : Int64) : Int64\n      entry = @@statements[stmt_id]\n      row = entry.current.not_nil!\n      v = row[i]\n      case v\n      when Nil     then 0_i64\n      when Int64   then v\n      when Int32   then v.to_i64\n      when Float64 then v.to_i64\n      when Float32 then v.to_i64\n      when Bool    then v ? 1_i64 : 0_i64\n      when String  then v.to_i64? || 0_i64\n      else              0_i64\n      end\n    end\n\n    # Read a text column at zero-based index. NULL coerces to \"\"\n    # (matches the TS shim — lowered code compares strings, never\n    # against nil). Bytes/numeric variants stringify via `to_s`.\n    def self.column_text(stmt_id : Int64, i : Int64) : String\n      entry = @@statements[stmt_id]\n      row = entry.current.not_nil!\n      v = row[i]\n      case v\n      when Nil    then \"\"\n      when String then v\n      else             v.to_s\n      end\n    end\n\n    # Release the underlying ResultSet and drop the stmt-table entry.\n    # Idempotent — finalize on an unknown stmt id is a no-op (mirrors\n    # the TS shim).\n    def self.finalize(stmt_id : Int64) : Nil\n      entry = @@statements[stmt_id]?\n      return if entry.nil?\n      entry.result_set.close\n      @@statements.delete(stmt_id)\n    end\n\n    def self.last_insert_rowid : Int64\n      @@last_insert_rowid\n    end\n\n    def self.changes : Int64\n      @@changes\n    end\n\n    # SQL-quote a string value. Single-quotes are doubled per sqlite's\n    # string-literal escape rule; no other byte transforms (the lowered\n    # adapter emit never inlines binary blobs).\n    def self.escape_string(s : String) : String\n      \"'\" + s.gsub(\"'\", \"''\") + \"'\"\n    end\n\n    # Render an Integer for SQL inlining. Matches the TS shim's\n    # truncate-to-int semantics; the Crystal type system already\n    # constrains the input to Int, so there's no parse-or-zero\n    # fallback.\n    def self.escape_int(n : Int) : String\n      n.to_s\n    end\n\n    # Render an integer list for `IN (...)` eager-load batches (issue\n    # #27). An empty list yields \"NULL\" so `IN (NULL)` is valid SQL\n    # matching no rows — an empty `IN ()` is a syntax error. Generic over\n    # the element type so it accepts Array(Int32)/Array(Int64) alike.\n    def self.escape_int_list(ids : Array(T)) : String forall T\n      return \"NULL\" if ids.empty?\n\n      ids.map(&.to_s).join(\", \")\n    end\n\n    # SQLite stores booleans as 0/1 integers (no native bool type) —\n    # mirrors the Ruby/spinel sibling shims.\n    def self.escape_bool(b : Bool) : String\n      b ? \"1\" : \"0\"\n    end\n\n    # Read a boolean column. SQLite returns 0/1 (integer), widen to Bool.\n    # Nulls coerce to false.\n    def self.column_bool(stmt : Int64, idx : Int) : Bool\n      column_int(stmt, idx) != 0\n    end\n\n    # Drain in-flight ResultSets before swapping the underlying\n    # connection. Without this, `setup_test_db` between specs would\n    # leak ResultSets bound to a closed connection.\n    private def self.reset_statements : Nil\n      @@statements.each_value do |entry|\n        entry.result_set.close rescue nil\n      end\n      @@statements.clear\n      @@next_id = 0_i64\n      @@last_insert_rowid = 0_i64\n      @@changes = 0_i64\n    end\n  end\n\n  # Abstract adapter contract — the 9 methods `ActiveRecord::Base`\n  # (transpiled from runtime/ruby/active_record/base.rb) calls\n  # against `ActiveRecord.adapter`. Every concrete adapter (sqlite\n  # production, in-memory framework-test, future libsql/D1) inherits\n  # and implements these. Returns are intentionally untyped here —\n  # row shape (`Hash(String, DB::Any)` for sqlite, `Hash(String, _)`\n  # for the in-memory adapter) varies by implementation, but per-\n  # call-site Crystal inference threads the actual concrete type\n  # through to `instantiate(row)`.\n  #\n  # Test-helper methods (`create_table`, `drop_table`, `reset_all!`,\n  # `schema`) are NOT in the abstract — they're called directly on\n  # test-only adapter mocks (when present), never via the\n  # `ActiveRecord.adapter` slot.\n  abstract class ActiveRecordAdapter\n    abstract def all(table_name : String)\n    abstract def find(table_name : String, id)\n    abstract def where(table_name : String, conditions : Hash(Symbol, _))\n    abstract def count(table_name : String) : Int64\n    abstract def exists?(table_name : String, id) : Bool\n    abstract def insert(table_name : String, attributes : Hash(Symbol, _)) : Int64\n    abstract def update(table_name : String, id, attributes : Hash(Symbol, _)) : Nil\n    abstract def delete(table_name : String, id) : Nil\n    abstract def truncate(table_name : String) : Nil\n  end\n\n  # Concrete sqlite-backed adapter. Method names + arities match the\n  # Ruby surface; row results come back as `Hash(String, DB::Any)`\n  # matching Crystal's crystal-db return shape.\n  class SqliteAdapter < ActiveRecordAdapter\n    private def conn\n      Roundhouse::Db.conn\n    end\n\n    def all(table_name : String)\n      rows = [] of Hash(String, DB::Any)\n      conn.query(\"SELECT * FROM #{table_name}\") do |rs|\n        rs.column_count.times { rs.column_name(0) } # warm up metadata\n        names = (0...rs.column_count).map { |i| rs.column_name(i) }\n        rs.each do\n          h = {} of String => DB::Any\n          names.each_with_index { |n, i| h[n] = rs.read }\n          rows << h\n        end\n      end\n      rows\n    end\n\n    def find(table_name : String, id)\n      row = nil\n      conn.query(\"SELECT * FROM #{table_name} WHERE id = ? LIMIT 1\", id) do |rs|\n        names = (0...rs.column_count).map { |i| rs.column_name(i) }\n        rs.each do\n          h = {} of String => DB::Any\n          names.each_with_index { |n, i| h[n] = rs.read }\n          row = h\n        end\n      end\n      row\n    end\n\n    def where(table_name : String, conditions : Hash(Symbol, _))\n      keys = conditions.keys\n      rows = [] of Hash(String, DB::Any)\n      return rows if keys.empty?\n      where_clause = keys.map { |k| \"#{k} = ?\" }.join(\" AND \")\n      args = keys.map { |k| conditions[k].as(DB::Any) }\n      conn.query(\"SELECT * FROM #{table_name} WHERE #{where_clause}\", args: args) do |rs|\n        names = (0...rs.column_count).map { |i| rs.column_name(i) }\n        rs.each do\n          h = {} of String => DB::Any\n          names.each_with_index { |n, i| h[n] = rs.read }\n          rows << h\n        end\n      end\n      rows\n    end\n\n    def count(table_name : String) : Int64\n      conn.query_one(\"SELECT COUNT(*) FROM #{table_name}\", as: Int64)\n    end\n\n    def exists?(table_name : String, id) : Bool\n      n = conn.query_one(\n        \"SELECT COUNT(*) FROM #{table_name} WHERE id = ?\",\n        id,\n        as: Int64,\n      )\n      n > 0\n    end\n\n    def insert(table_name : String, attributes : Hash(Symbol, _)) : Int64\n      keys = attributes.keys\n      cols = keys.map(&.to_s).join(\", \")\n      placeholders = ([\"?\"] * keys.size).join(\", \")\n      args = keys.map { |k| attributes[k].as(DB::Any) }\n      conn.exec(\"INSERT INTO #{table_name} (#{cols}) VALUES (#{placeholders})\", args: args)\n      conn.query_one(\"SELECT last_insert_rowid()\", as: Int64)\n    end\n\n    def update(table_name : String, id, attributes : Hash(Symbol, _)) : Nil\n      keys = attributes.keys\n      return if keys.empty?\n      sets = keys.map { |k| \"#{k} = ?\" }.join(\", \")\n      args = keys.map { |k| attributes[k].as(DB::Any) } + [id.as(DB::Any)]\n      conn.exec(\"UPDATE #{table_name} SET #{sets} WHERE id = ?\", args: args)\n    end\n\n    def delete(table_name : String, id) : Nil\n      conn.exec(\"DELETE FROM #{table_name} WHERE id = ?\", id)\n    end\n\n    def truncate(table_name : String) : Nil\n      conn.exec(\"DELETE FROM #{table_name}\")\n    end\n  end\nend\n\n# Module-level attr_accessor analog. The Ruby source declares\n# `class << self; attr_accessor :adapter; end` inside `module\n# ActiveRecord`; the transpiler doesn't yet emit module-metaclass\n# accessors. Re-opening the module here adds the missing surface.\n#\n# Slot is typed as the abstract base so any adapter implementation\n# (production sqlite, framework-test in-memory, future libsql/D1)\n# can plug in via `ActiveRecord.adapter = <impl>`.\nmodule ActiveRecord\n  # Phantom class the analyzer registers for the adapter slot type;\n  # `runtime/ruby/active_record/base.rbs` declares the slot as\n  # `() -> ActiveRecord::AdapterInterface`. Each target maps the name\n  # onto its concrete adapter type — Crystal points it at the abstract\n  # base in `Roundhouse::ActiveRecordAdapter` so the transpiled\n  # `property adapter : ActiveRecord::AdapterInterface?` resolves.\n  alias AdapterInterface = Roundhouse::ActiveRecordAdapter\n\n  @@adapter : Roundhouse::ActiveRecordAdapter? = nil\n\n  def self.adapter : Roundhouse::ActiveRecordAdapter\n    @@adapter.not_nil!\n  end\n\n  def self.adapter=(value : Roundhouse::ActiveRecordAdapter) : Roundhouse::ActiveRecordAdapter\n    @@adapter = value\n  end\nend\n"},{"path":"src/errors.cr","content":"# Generated from runtime/ruby/active_record/errors.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nmodule ActiveRecord\n  class RecordNotFound < Exception\n    def initialize(message : String = \"ActiveRecord::RecordNotFound\") : Nil\n      super(message)\n    end\n  end\nend\n\nmodule ActiveRecord\n  class RecordInvalid < Exception\n    property record : ActiveRecord::Base\n\n    def initialize(record : ActiveRecord::Base) : Nil\n      @record = record\n      super(\"Validation failed: #{record.errors.join(\", \")}\")\n    end\n  end\nend\n"},{"path":"src/fixtures/articles.cr","content":"module ArticlesFixtures\n  def self.one : Article\n    Article.find(1_i64)\n  end\n\n  def self.two : Article\n    Article.find(2_i64)\n  end\n\n  def self._fixtures_load! : Nil\n    instance = Article.new\n    instance.id = 1_i64\n    instance.title = \"Getting Started with Rails\"\n    instance.body = \"Rails is a web application framework running on the Ruby programming language.\"\n    instance.save.not_nil!\n    instance = Article.new\n    instance.id = 2_i64\n    instance.title = \"Understanding MVC Architecture\"\n    instance.body = \"MVC stands for Model-View-Controller. Models handle data and business logic.\"\n    instance.save.not_nil!\n  end\nend\n"},{"path":"src/fixtures/comments.cr","content":"module CommentsFixtures\n  def self.one : Comment\n    Comment.find(1_i64)\n  end\n\n  def self.two : Comment\n    Comment.find(2_i64)\n  end\n\n  def self._fixtures_load! : Nil\n    instance = Comment.new\n    instance.id = 1_i64\n    instance.article_id = 1_i64\n    instance.commenter = \"Alice\"\n    instance.body = \"Great introduction! Rails really does make development faster.\"\n    instance.save.not_nil!\n    instance = Comment.new\n    instance.id = 2_i64\n    instance.article_id = 2_i64\n    instance.commenter = \"Bob\"\n    instance.body = \"This pattern really helps keep code organized!\"\n    instance.save.not_nil!\n  end\nend\n"},{"path":"src/flash.cr","content":"# Generated from runtime/ruby/action_dispatch/flash.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nmodule ActionDispatch\n  class Flash\n    property notice : String?\n    property alert : String?\n    property notice_was : String?\n    property alert_was : String?\n\n    def initialize(other : Hash(String, String)? = nil) : Nil\n      @notice = nil\n      @alert = nil\n      @notice_was = nil\n      @alert_was = nil\n      return if other.nil?\n      other.each do |k, v|\n        if k == \"notice\"\n          @notice = v\n          @notice_was = v\n        else\n          if k == \"alert\"\n            @alert = v\n            @alert_was = v\n          end\n        end\n      end\n    end\n\n    def [](key)\n      k = key.to_s\n      return @notice if k == \"notice\"\n      return @alert if k == \"alert\"\n      nil\n    end\n\n    def []=(key, value)\n      k = key.to_s\n      if k == \"notice\"\n        @notice = value\n      else\n        @alert = value if k == \"alert\"\n      end\n      value\n    end\n\n    def fetch(key, default = nil)\n      v = self[key]\n      return v if !(v.nil?)\n      default\n    end\n\n    def key?(key)\n      v = self[key]\n      !(v.nil?)\n    end\n\n    def has_key?(key)\n      key?(key)\n    end\n\n    def include?(key)\n      key?(key)\n    end\n\n    def delete(key)\n      k = key.to_s\n      if k == \"notice\"\n        v = @notice\n        @notice = nil\n        return v\n      end\n      if k == \"alert\"\n        v = @alert\n        @alert = nil\n        return v\n      end\n      nil\n    end\n\n    def length\n      n = 0_i64\n      n += 1_i64 if !(@notice.nil?)\n      n += 1_i64 if !(@alert.nil?)\n      n\n    end\n\n    def size\n      n = 0_i64\n      n += 1_i64 if !(@notice.nil?)\n      n += 1_i64 if !(@alert.nil?)\n      n\n    end\n\n    def empty? : Bool\n      @notice.nil? && @alert.nil?\n    end\n\n    def keys : Array(String)\n      result = [] of String\n      result.push(\"notice\") if !(@notice.nil?)\n      result.push(\"alert\") if !(@alert.nil?)\n      result\n    end\n\n    def values : Array(String)\n      result = [] of String\n      result.push(@notice) if !(@notice.nil?)\n      result.push(@alert) if !(@alert.nil?)\n      result\n    end\n\n    def each\n      yield \"notice\", @notice if !(@notice.nil?)\n      yield \"alert\", @alert if !(@alert.nil?)\n      self\n    end\n\n    def to_h : Hash(String, String)\n      result = {} of String => String\n      result[\"notice\"] = @notice if !(@notice.nil?)\n      result[\"alert\"] = @alert if !(@alert.nil?)\n      result\n    end\n\n    def to_persisted : Hash(String, String)\n      result = {} of String => String\n      n = @notice\n      result[\"notice\"] = n if !(n.nil?) && n != @notice_was\n      a = @alert\n      result[\"alert\"] = a if !(a.nil?) && a != @alert_was\n      result\n    end\n\n    def merge(other : Hash(String, String))\n      result = ::ActionDispatch::Flash.new\n      result.notice = @notice\n      result.alert = @alert\n      other.each do |k, v| result[k] = v end\n      result\n    end\n  end\nend\n"},{"path":"src/http.cr","content":"# Roundhouse Crystal HTTP runtime.\n#\n# Hand-written, shipped alongside generated code (copied in by the\n# Crystal emitter as `src/http.cr`). Provides the Roundhouse::Http\n# surface that emitted controller actions call into: ActionResponse\n# (typed return value), ActionContext (params + request shape),\n# Router (in-memory route-match table), plus legacy stubs for the\n# Phase 4c controller shape (render/redirect_to/head/respond_to)\n# that the preview emitters still reference.\n#\n# Mirrors `runtime/rust/http.rs` / `runtime/typescript/juntos.ts` in\n# shape and posture: pure in-process dispatch via `Router.match`\n# means tests call controller actions directly — no HTTP server,\n# no sockets, no event-loop glue. A real HTTP transport slots in\n# later by adding a `HTTP::Handler` on top of the same match table.\n\nmodule Roundhouse\n  module Http\n    # What every controller action returns. Fields are optional so\n    # actions pick the shape they need:\n    #   - `body`: the HTML string the view rendered (for GET actions)\n    #   - `status`: HTTP status code (default 200; 422 for\n    #     unprocessable, 303 for redirects)\n    #   - `location`: redirect target URL; test assertions on\n    #     `assert_redirected_to` check this field.\n    class ActionResponse\n      property body : String\n      property status : Int32\n      property location : String\n\n      def initialize(@body : String = \"\", @status : Int32 = 200, @location : String = \"\")\n      end\n    end\n\n    # Context passed to every action. `params` merges path params +\n    # form body. Values are always strings — controllers coerce to\n    # integers via `.to_i64` at the boundary.\n    class ActionContext\n      getter params : Hash(String, String)\n\n      def initialize(@params : Hash(String, String) = {} of String => String)\n      end\n    end\n\n    # One entry in the router's match table. The handler is a proc\n    # taking ActionContext and returning ActionResponse.\n    alias Handler = ActionContext -> ActionResponse\n\n    record Route, method : String, path : String, handler : Handler\n\n    # In-memory router + dispatch. Controllers register routes at\n    # require time (the emitted `src/routes.cr` runs Router.root /\n    # Router.resources at top level); tests dispatch through\n    # `Router.match` without a live HTTP server.\n    class Router\n      @@routes : Array(Route) = [] of Route\n\n      def self.reset : Nil\n        @@routes.clear\n      end\n\n      def self.add(method : String, path : String, handler : Handler) : Nil\n        @@routes << Route.new(method, path, handler)\n      end\n\n      # Match a request path against the registered routes; return\n      # the handler + extracted path params, or nil. Path params\n      # come from `:id`-style segments in the route pattern.\n      def self.match(method : String, path : String) : {Handler, Hash(String, String)}?\n        @@routes.each do |route|\n          next unless route.method == method\n          if extracted = try_match(route.path, path)\n            return {route.handler, extracted}\n          end\n        end\n        nil\n      end\n\n      private def self.try_match(pattern : String, path : String) : Hash(String, String)?\n        pat_parts = pattern.split('/').reject(&.empty?)\n        path_parts = path.split('/').reject(&.empty?)\n        return nil unless pat_parts.size == path_parts.size\n        params = {} of String => String\n        pat_parts.zip(path_parts).each do |pat, seg|\n          if pat.starts_with?(':')\n            params[pat[1..]] = seg\n          elsif pat != seg\n            return nil\n          end\n        end\n        params\n      end\n    end\n\n    # Legacy Phase-4c stubs kept for the compile-only pass of\n    # controllers still using respond_to/render; pass-2 template\n    # actions don't call these.\n    class Response\n      def initialize\n      end\n    end\n\n    class Params\n      def expect(*args, **kwargs) : Params\n        self\n      end\n\n      def [](key) : Int64\n        0_i64\n      end\n    end\n\n    def self.params : Params\n      Params.new\n    end\n\n    def self.render(*args, **kwargs) : Response\n      Response.new\n    end\n\n    def self.redirect_to(*args, **kwargs) : Response\n      Response.new\n    end\n\n    def self.head(*args, **kwargs) : Response\n      Response.new\n    end\n\n    def self.respond_to(&) : Response\n      fr = FormatRouter.new\n      yield fr\n      Response.new\n    end\n\n    class FormatRouter\n      def html(&) : Response\n        yield\n        Response.new\n      end\n\n      def json(&) : Response\n        yield\n        Response.new\n      end\n    end\n  end\nend\n"},{"path":"src/importmap.cr","content":"module Importmap\n  def self.pins : Array(NamedTuple(name: String, path: String))\n    [{name: \"application\", path: \"/assets/application.js\"}, {name: \"@hotwired/turbo-rails\", path: \"/assets/turbo.min.js\"}, {name: \"@hotwired/stimulus\", path: \"/assets/stimulus.min.js\"}, {name: \"@hotwired/stimulus-loading\", path: \"/assets/stimulus-loading.js\"}, {name: \"controllers/application\", path: \"/assets/controllers/application.js\"}, {name: \"controllers/hello_controller\", path: \"/assets/controllers/hello_controller.js\"}, {name: \"controllers\", path: \"/assets/controllers/index.js\"}]\n  end\n\n  def self.entry : String\n    \"application\"\n  end\nend\n"},{"path":"src/inflector.cr","content":"# Generated from runtime/ruby/inflector.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nmodule Inflector\n  def self.pluralize(count : Int64, word : String) : String\n    if count == 1_i64\n      \"1 #{word}\"\n    else\n      \"#{count} #{word}s\"\n    end\n  end\nend\n"},{"path":"src/json_builder.cr","content":"# Generated from runtime/ruby/json_builder.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nESCAPES = { \"\\\\\" => \"\\\\\\\\\", \"\\\"\" => \"\\\\\\\"\", \"\\n\" => \"\\\\n\", \"\\r\" => \"\\\\r\", \"\\t\" => \"\\\\t\", \"\\u{8}\" => \"\\\\b\", \"\\u{c}\" => \"\\\\f\" }\nESCAPE_PATTERN = /[\\\\\"\\n\\r\\t\\x08\\f]/\n\nmodule JsonBuilder\n  def self.encode_string(s : String) : String\n    s.gsub(ESCAPE_PATTERN, ESCAPES)\n  end\n\n  def self.encode_value(v)\n    return \"null\" if v.nil?\n    return \"true\" if v == true\n    return \"false\" if v == false\n    return v.to_s if v.is_a?(Int)\n    return v.to_s if v.is_a?(Float)\n    return \"\\\"#{encode_string(v)}\\\"\" if v.is_a?(String)\n    \"\\\"#{encode_string(v.to_s)}\\\"\"\n  end\n\n  def self.encode_datetime(s : String?) : String\n    return \"null\" if s.nil?\n    str = s.to_s\n    return \"\\\"#{encode_string(str)}\\\"\" if str.size < 19_i64\n    date = str[0_i64, 10_i64]\n    time = str[11_i64, 8_i64]\n    ms = \"000\"\n    if str.size > 20_i64 && str[19_i64, 1_i64] == \".\"\n      frac = str[20_i64..]\n      padded = \"#{frac}000\"\n      ms = padded[0_i64, 3_i64]\n    end\n    \"\\\"#{date}T#{time}.#{ms}Z\\\"\"\n  end\nend\n"},{"path":"src/main.cr","content":"# Generated by Roundhouse — Crystal binary entry point.\nrequire \"./app\"\n\nRoundhouse::Server.start(\n  schema_sql: Schema.statements.join(\";\\n\"),\n  routes: Routes.table,\n  root_route: Routes.root,\n  layout: ->(body : String) { Views::Layouts.application(body) },\n  controllers: {\n    :application => ApplicationController,\n    :articles => ArticlesController,\n    :comments => CommentsController,\n  } of Symbol => ActionController::Base.class,\n)\n"},{"path":"src/models/application_record.cr","content":"class ApplicationRecord < ActiveRecord::Base\n  def self.abstract? : Bool\n    true\n  end\nend\n"},{"path":"src/models/article.cr","content":"class Article < ApplicationRecord\n  property body : String?\n  property created_at : String?\n  property title : String?\n  property updated_at : String?\n  @comments_cache : Array(Comment) = [] of Comment\n  @comments_loaded : Bool = false\n\n  def self.table_name : String\n    \"articles\"\n  end\n\n  def self.schema_columns : Array(Symbol)\n    [:id, :body, :created_at, :title, :updated_at]\n  end\n\n  def self.instantiate(row)\n    instance = Article.from_row(ArticleRow.from_raw(row))\n    instance.mark_persisted!\n    instance\n  end\n\n  def self.from_row(row : ArticleRow)\n    instance = Article.new\n    instance.id = (row.id.not_nil!).as?(Int64).not_nil!\n    instance.body = (row.body.not_nil!).as?(String).not_nil!\n    instance.created_at = (row.created_at.not_nil!).as?(String).not_nil!\n    instance.title = (row.title.not_nil!).as?(String).not_nil!\n    instance.updated_at = (row.updated_at.not_nil!).as?(String).not_nil!\n    instance\n  end\n\n  def self.from_stmt(stmt : Int64)\n    instance = Article.new\n    instance.id = Roundhouse::Db.column_int(stmt, 0_i64)\n    instance.body = Roundhouse::Db.column_text(stmt, 1_i64)\n    instance.created_at = Roundhouse::Db.column_text(stmt, 2_i64)\n    instance.title = Roundhouse::Db.column_text(stmt, 3_i64)\n    instance.updated_at = Roundhouse::Db.column_text(stmt, 4_i64)\n    instance.mark_persisted!\n    instance\n  end\n\n  def assign_from_row(row)\n    self.id = row[\"id\"]\n    self.body = row[\"body\"]\n    self.created_at = row[\"created_at\"]\n    self.title = row[\"title\"]\n    self.updated_at = row[\"updated_at\"]\n  end\n\n  def attributes\n    {body: @body.not_nil!, created_at: @created_at.not_nil!, title: @title.not_nil!, updated_at: @updated_at.not_nil!}\n  end\n\n  def [](name)\n    case name\n    when :id\n      @id\n    when :body\n      @body\n    when :created_at\n      @created_at\n    when :title\n      @title\n    when :updated_at\n      @updated_at\n    end\n  end\n\n  def []=(name : Symbol, value : Int64 | String) : Int64 | String | Nil\n    case name\n    when :id\n      @id = (value).as?(Int64).not_nil!\n    when :body\n      @body = (value).as?(String).not_nil!\n    when :created_at\n      @created_at = (value).as?(String).not_nil!\n    when :title\n      @title = (value).as?(String).not_nil!\n    when :updated_at\n      @updated_at = (value).as?(String).not_nil!\n    end\n  end\n\n  def update(p : ArticleParams) : Bool\n    if p.title.not_nil!.nil?\n      nil\n    else\n      self.title = p.title.not_nil!\n    end\n    if p.body.not_nil!.nil?\n      nil\n    else\n      self.body = p.body.not_nil!\n    end\n    save.not_nil!\n  end\n\n  def self._adapter_find_by_id(id : Int64)\n    stmt = Roundhouse::Db.prepare(\"SELECT id, body, created_at, title, updated_at FROM articles\" + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(id) + \" LIMIT 1\")\n    result = nil\n    if Roundhouse::Db.step?(stmt)\n      result = Article.from_stmt(stmt)\n    end\n    Roundhouse::Db.finalize(stmt)\n    result\n  end\n\n  def self._adapter_all\n    stmt = Roundhouse::Db.prepare(\"SELECT id, body, created_at, title, updated_at FROM articles\")\n    results = [] of Article\n    while Roundhouse::Db.step?(stmt)\n      results << Article.from_stmt(stmt)\n    end\n    Roundhouse::Db.finalize(stmt)\n    results\n  end\n\n  def _adapter_insert : Int64\n    Roundhouse::Db.exec(\"INSERT INTO articles (body, created_at, title, updated_at) VALUES (\" + Roundhouse::Db.escape_string(@body.not_nil!) + \", \" + Roundhouse::Db.escape_string(@created_at.not_nil!) + \", \" + Roundhouse::Db.escape_string(@title.not_nil!) + \", \" + Roundhouse::Db.escape_string(@updated_at.not_nil!) + \")\")\n    Roundhouse::Db.last_insert_rowid.not_nil!\n  end\n\n  def _adapter_update : Nil\n    Roundhouse::Db.exec(\"UPDATE articles SET \" + \"body = \" + Roundhouse::Db.escape_string(@body.not_nil!) + \", created_at = \" + Roundhouse::Db.escape_string(@created_at.not_nil!) + \", title = \" + Roundhouse::Db.escape_string(@title.not_nil!) + \", updated_at = \" + Roundhouse::Db.escape_string(@updated_at.not_nil!) + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(@id.not_nil!))\n  end\n\n  def _adapter_delete : Nil\n    Roundhouse::Db.exec(\"DELETE FROM articles\" + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(@id.not_nil!))\n  end\n\n  def self._adapter_count : Int64\n    stmt = Roundhouse::Db.prepare(\"SELECT COUNT(*) FROM articles\")\n    Roundhouse::Db.step?(stmt)\n    result = Roundhouse::Db.column_int(stmt, 0_i64)\n    Roundhouse::Db.finalize(stmt)\n    result\n  end\n\n  def self._adapter_exists_by_id?(id : Int64) : Bool\n    stmt = Roundhouse::Db.prepare(\"SELECT 1 FROM articles\" + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(id) + \" LIMIT 1\")\n    result = Roundhouse::Db.step?(stmt)\n    Roundhouse::Db.finalize(stmt)\n    result\n  end\n\n  def self._adapter_truncate : Nil\n    Roundhouse::Db.exec(\"DELETE FROM articles\")\n    Roundhouse::Db.exec(\"DELETE FROM sqlite_sequence WHERE name = 'articles'\")\n  end\n\n  def _adapter_reload\n    stmt = Roundhouse::Db.prepare(\"SELECT id, body, created_at, title, updated_at FROM articles WHERE id = \" + Roundhouse::Db.escape_int(@id.not_nil!) + \" LIMIT 1\")\n    if Roundhouse::Db.step?(stmt)\n      @id = Roundhouse::Db.column_int(stmt, 0_i64)\n      @body = Roundhouse::Db.column_text(stmt, 1_i64)\n      @created_at = Roundhouse::Db.column_text(stmt, 2_i64)\n      @title = Roundhouse::Db.column_text(stmt, 3_i64)\n      @updated_at = Roundhouse::Db.column_text(stmt, 4_i64)\n      mark_persisted!\n    end\n    Roundhouse::Db.finalize(stmt)\n    self\n  end\n\n  def self.from_params(p : ArticleParams)\n    instance = Article.new\n    instance.title = p.title.not_nil!\n    instance.body = p.body.not_nil!\n    instance\n  end\n\n  def validate : Nil\n    errors << \"Title can't be blank\" if @title.nil? || @title.not_nil!.empty?\n    errors << \"Body can't be blank\" if @body.nil? || @body.not_nil!.empty?\n    if !(@body.nil?)\n      len = @body.not_nil!.size\n      errors << \"Body is too short (minimum is 10 characters)\" if len < 10_i64\n    end\n  end\n\n  def comments : Array(Comment)\n    return @comments_cache.not_nil! if @comments_loaded.not_nil!\n    stmt = Roundhouse::Db.prepare(\"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments\" + \" WHERE \" + \"article_id = \" + Roundhouse::Db.escape_int(@id.not_nil!))\n    results = [] of Comment\n    while Roundhouse::Db.step?(stmt)\n      results << Comment.from_stmt(stmt)\n    end\n    Roundhouse::Db.finalize(stmt)\n    results\n  end\n\n  def _preload_comments(list : Array(Comment)) : Nil\n    @comments_cache = list\n    @comments_loaded = true\n  end\n\n  def before_destroy : Nil\n    comments.each { |c| c.destroy }\n  end\n\n  def dom_prefix : String\n    \"article\"\n  end\n\n  def after_create_commit : Nil\n    Broadcasts.prepend(stream: \"articles\", target: \"articles\", html: Views::Articles.article(self))\n  end\n\n  def after_update_commit : Nil\n    Broadcasts.replace(stream: \"articles\", target: \"article_#{@id.not_nil!}\", html: Views::Articles.article(self))\n  end\n\n  def after_destroy_commit : Nil\n    Broadcasts.remove(stream: \"articles\", target: \"article_#{@id.not_nil!}\")\n  end\nend\n"},{"path":"src/models/article_params.cr","content":"class ArticleParams\n  property title : String\n  property body : String\n\n  def initialize : Nil\n    @title = \"\"\n    @body = \"\"\n  end\n\n  def self.from_raw(params : Hash(String, Roundhouse::ParamValue))\n    raw_sub = params.fetch \"article\", {} of String => Roundhouse::ParamValue\n    sub = if (raw_sub.is_a?(Hash) || raw_sub.is_a?(NamedTuple))\n      (raw_sub).as?(Hash(String, Roundhouse::ParamValue)).not_nil!\n    else\n      {} of String => Roundhouse::ParamValue\n    end\n    instance = ArticleParams.new\n    raw_title = sub.fetch \"title\", \"\"\n    instance.title = if raw_title.is_a?(String)\n      raw_title\n    else\n      \"\"\n    end\n    raw_body = sub.fetch \"body\", \"\"\n    instance.body = if raw_body.is_a?(String)\n      raw_body\n    else\n      \"\"\n    end\n    instance\n  end\n\n  def to_h : Hash(String, String)\n    { \"title\" => @title.not_nil!, \"body\" => @body.not_nil! }\n  end\nend\n"},{"path":"src/models/article_row.cr","content":"class ArticleRow\n  property id : Int64\n  property body : String\n  property created_at : String\n  property title : String\n  property updated_at : String\n\n  def initialize : Nil\n    @id = 0_i64\n    @body = \"\"\n    @created_at = \"\"\n    @title = \"\"\n    @updated_at = \"\"\n  end\n\n  def self.from_raw(row)\n    instance = ArticleRow.new\n    instance.id = (row[\"id\"]? || 0_i64).as?(Int64).not_nil!\n    instance.body = (row[\"body\"]).as?(String).not_nil!\n    instance.created_at = (row[\"created_at\"]).as?(String).not_nil!\n    instance.title = (row[\"title\"]).as?(String).not_nil!\n    instance.updated_at = (row[\"updated_at\"]).as?(String).not_nil!\n    instance\n  end\nend\n"},{"path":"src/models/comment.cr","content":"class Comment < ApplicationRecord\n  property article_id : Int64?\n  property body : String?\n  property commenter : String?\n  property created_at : String?\n  property updated_at : String?\n\n  def self.table_name : String\n    \"comments\"\n  end\n\n  def self.schema_columns : Array(Symbol)\n    [:id, :article_id, :body, :commenter, :created_at, :updated_at]\n  end\n\n  def self.instantiate(row)\n    instance = Comment.from_row(CommentRow.from_raw(row))\n    instance.mark_persisted!\n    instance\n  end\n\n  def self.from_row(row : CommentRow)\n    instance = Comment.new\n    instance.id = (row.id.not_nil!).as?(Int64).not_nil!\n    instance.article_id = (row.article_id.not_nil!).as?(Int64).not_nil!\n    instance.body = (row.body.not_nil!).as?(String).not_nil!\n    instance.commenter = (row.commenter.not_nil!).as?(String).not_nil!\n    instance.created_at = (row.created_at.not_nil!).as?(String).not_nil!\n    instance.updated_at = (row.updated_at.not_nil!).as?(String).not_nil!\n    instance\n  end\n\n  def self.from_stmt(stmt : Int64)\n    instance = Comment.new\n    instance.id = Roundhouse::Db.column_int(stmt, 0_i64)\n    instance.article_id = Roundhouse::Db.column_int(stmt, 1_i64)\n    instance.body = Roundhouse::Db.column_text(stmt, 2_i64)\n    instance.commenter = Roundhouse::Db.column_text(stmt, 3_i64)\n    instance.created_at = Roundhouse::Db.column_text(stmt, 4_i64)\n    instance.updated_at = Roundhouse::Db.column_text(stmt, 5_i64)\n    instance.mark_persisted!\n    instance\n  end\n\n  def assign_from_row(row)\n    self.id = row[\"id\"]\n    self.article_id = row[\"article_id\"]\n    self.body = row[\"body\"]\n    self.commenter = row[\"commenter\"]\n    self.created_at = row[\"created_at\"]\n    self.updated_at = row[\"updated_at\"]\n  end\n\n  def attributes\n    {article_id: @article_id.not_nil!, body: @body.not_nil!, commenter: @commenter.not_nil!, created_at: @created_at.not_nil!, updated_at: @updated_at.not_nil!}\n  end\n\n  def [](name)\n    case name\n    when :id\n      @id\n    when :article_id\n      @article_id\n    when :body\n      @body\n    when :commenter\n      @commenter\n    when :created_at\n      @created_at\n    when :updated_at\n      @updated_at\n    end\n  end\n\n  def []=(name : Symbol, value : Int64 | String) : Int64 | String | Nil\n    case name\n    when :id\n      @id = (value).as?(Int64).not_nil!\n    when :article_id\n      @article_id = (value).as?(Int64).not_nil!\n    when :body\n      @body = (value).as?(String).not_nil!\n    when :commenter\n      @commenter = (value).as?(String).not_nil!\n    when :created_at\n      @created_at = (value).as?(String).not_nil!\n    when :updated_at\n      @updated_at = (value).as?(String).not_nil!\n    end\n  end\n\n  def update(p : CommentParams) : Bool\n    if p.commenter.not_nil!.nil?\n      nil\n    else\n      self.commenter = p.commenter.not_nil!\n    end\n    if p.body.not_nil!.nil?\n      nil\n    else\n      self.body = p.body.not_nil!\n    end\n    save.not_nil!\n  end\n\n  def self._adapter_find_by_id(id : Int64)\n    stmt = Roundhouse::Db.prepare(\"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments\" + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(id) + \" LIMIT 1\")\n    result = nil\n    if Roundhouse::Db.step?(stmt)\n      result = Comment.from_stmt(stmt)\n    end\n    Roundhouse::Db.finalize(stmt)\n    result\n  end\n\n  def self._adapter_all\n    stmt = Roundhouse::Db.prepare(\"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments\")\n    results = [] of Comment\n    while Roundhouse::Db.step?(stmt)\n      results << Comment.from_stmt(stmt)\n    end\n    Roundhouse::Db.finalize(stmt)\n    results\n  end\n\n  def _adapter_insert : Int64\n    Roundhouse::Db.exec(\"INSERT INTO comments (article_id, body, commenter, created_at, updated_at) VALUES (\" + Roundhouse::Db.escape_int(@article_id.not_nil!) + \", \" + Roundhouse::Db.escape_string(@body.not_nil!) + \", \" + Roundhouse::Db.escape_string(@commenter.not_nil!) + \", \" + Roundhouse::Db.escape_string(@created_at.not_nil!) + \", \" + Roundhouse::Db.escape_string(@updated_at.not_nil!) + \")\")\n    Roundhouse::Db.last_insert_rowid.not_nil!\n  end\n\n  def _adapter_update : Nil\n    Roundhouse::Db.exec(\"UPDATE comments SET \" + \"article_id = \" + Roundhouse::Db.escape_int(@article_id.not_nil!) + \", body = \" + Roundhouse::Db.escape_string(@body.not_nil!) + \", commenter = \" + Roundhouse::Db.escape_string(@commenter.not_nil!) + \", created_at = \" + Roundhouse::Db.escape_string(@created_at.not_nil!) + \", updated_at = \" + Roundhouse::Db.escape_string(@updated_at.not_nil!) + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(@id.not_nil!))\n  end\n\n  def _adapter_delete : Nil\n    Roundhouse::Db.exec(\"DELETE FROM comments\" + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(@id.not_nil!))\n  end\n\n  def self._adapter_count : Int64\n    stmt = Roundhouse::Db.prepare(\"SELECT COUNT(*) FROM comments\")\n    Roundhouse::Db.step?(stmt)\n    result = Roundhouse::Db.column_int(stmt, 0_i64)\n    Roundhouse::Db.finalize(stmt)\n    result\n  end\n\n  def self._adapter_exists_by_id?(id : Int64) : Bool\n    stmt = Roundhouse::Db.prepare(\"SELECT 1 FROM comments\" + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(id) + \" LIMIT 1\")\n    result = Roundhouse::Db.step?(stmt)\n    Roundhouse::Db.finalize(stmt)\n    result\n  end\n\n  def self._adapter_truncate : Nil\n    Roundhouse::Db.exec(\"DELETE FROM comments\")\n    Roundhouse::Db.exec(\"DELETE FROM sqlite_sequence WHERE name = 'comments'\")\n  end\n\n  def _adapter_reload\n    stmt = Roundhouse::Db.prepare(\"SELECT id, article_id, body, commenter, created_at, updated_at FROM comments WHERE id = \" + Roundhouse::Db.escape_int(@id.not_nil!) + \" LIMIT 1\")\n    if Roundhouse::Db.step?(stmt)\n      @id = Roundhouse::Db.column_int(stmt, 0_i64)\n      @article_id = Roundhouse::Db.column_int(stmt, 1_i64)\n      @body = Roundhouse::Db.column_text(stmt, 2_i64)\n      @commenter = Roundhouse::Db.column_text(stmt, 3_i64)\n      @created_at = Roundhouse::Db.column_text(stmt, 4_i64)\n      @updated_at = Roundhouse::Db.column_text(stmt, 5_i64)\n      mark_persisted!\n    end\n    Roundhouse::Db.finalize(stmt)\n    self\n  end\n\n  def self.from_params(p : CommentParams)\n    instance = Comment.new\n    instance.commenter = p.commenter.not_nil!\n    instance.body = p.body.not_nil!\n    instance\n  end\n\n  def validate : Nil\n    errors << \"Commenter can't be blank\" if @commenter.nil? || @commenter.not_nil!.empty?\n    errors << \"Body can't be blank\" if @body.nil? || @body.not_nil!.empty?\n    errors << \"Article must exist\" if @article_id.nil? || @article_id.not_nil! == 0_i64 || !(Article.exists?(@article_id.not_nil!))\n  end\n\n  def article : Article?\n    if @article_id.not_nil! == 0_i64\n      nil\n    else\n      stmt = Roundhouse::Db.prepare(\"SELECT id, body, created_at, title, updated_at FROM articles\" + \" WHERE \" + \"id = \" + Roundhouse::Db.escape_int(@article_id.not_nil!) + \" LIMIT 1\")\n      result = nil\n      if Roundhouse::Db.step?(stmt)\n        result = Article.from_stmt(stmt)\n      end\n      Roundhouse::Db.finalize(stmt)\n      result\n    end\n  end\n\n  def dom_prefix : String\n    \"comment\"\n  end\n\n  def after_create_commit : Nil\n    Broadcasts.append(stream: \"article_#{@article_id.not_nil!}_comments\", target: \"comments\", html: Views::Comments.comment(self))\n    parent = article\n    return if parent.nil?\n    Broadcasts.replace(stream: \"articles\", target: \"article_#{parent.id.not_nil!}\", html: Views::Articles.article(parent))\n  end\n\n  def after_update_commit : Nil\n    Broadcasts.replace(stream: \"article_#{@article_id.not_nil!}_comments\", target: \"comment_#{@id.not_nil!}\", html: Views::Comments.comment(self))\n  end\n\n  def after_destroy_commit : Nil\n    Broadcasts.remove(stream: \"article_#{@article_id.not_nil!}_comments\", target: \"comment_#{@id.not_nil!}\")\n    parent = article\n    return if parent.nil?\n    Broadcasts.replace(stream: \"articles\", target: \"article_#{parent.id.not_nil!}\", html: Views::Articles.article(parent))\n  end\nend\n"},{"path":"src/models/comment_params.cr","content":"class CommentParams\n  property commenter : String\n  property body : String\n\n  def initialize : Nil\n    @commenter = \"\"\n    @body = \"\"\n  end\n\n  def self.from_raw(params : Hash(String, Roundhouse::ParamValue))\n    raw_sub = params.fetch \"comment\", {} of String => Roundhouse::ParamValue\n    sub = if (raw_sub.is_a?(Hash) || raw_sub.is_a?(NamedTuple))\n      (raw_sub).as?(Hash(String, Roundhouse::ParamValue)).not_nil!\n    else\n      {} of String => Roundhouse::ParamValue\n    end\n    instance = CommentParams.new\n    raw_commenter = sub.fetch \"commenter\", \"\"\n    instance.commenter = if raw_commenter.is_a?(String)\n      raw_commenter\n    else\n      \"\"\n    end\n    raw_body = sub.fetch \"body\", \"\"\n    instance.body = if raw_body.is_a?(String)\n      raw_body\n    else\n      \"\"\n    end\n    instance\n  end\n\n  def to_h : Hash(String, String)\n    { \"commenter\" => @commenter.not_nil!, \"body\" => @body.not_nil! }\n  end\nend\n"},{"path":"src/models/comment_row.cr","content":"class CommentRow\n  property id : Int64\n  property article_id : Int64\n  property body : String\n  property commenter : String\n  property created_at : String\n  property updated_at : String\n\n  def initialize : Nil\n    @id = 0_i64\n    @article_id = 0_i64\n    @body = \"\"\n    @commenter = \"\"\n    @created_at = \"\"\n    @updated_at = \"\"\n  end\n\n  def self.from_raw(row)\n    instance = CommentRow.new\n    instance.id = (row[\"id\"]? || 0_i64).as?(Int64).not_nil!\n    instance.article_id = (row[\"article_id\"]? || 0_i64).as?(Int64).not_nil!\n    instance.body = (row[\"body\"]).as?(String).not_nil!\n    instance.commenter = (row[\"commenter\"]).as?(String).not_nil!\n    instance.created_at = (row[\"created_at\"]).as?(String).not_nil!\n    instance.updated_at = (row[\"updated_at\"]).as?(String).not_nil!\n    instance\n  end\nend\n"},{"path":"src/param_value.cr","content":"# Recursive type alias for request parameters.\n#\n# Form bodies and URL params arrive as a tree of String leaves, Hashes\n# keyed by String, and Arrays. Rails' Rack parser walks\n# `comment[author][name]=x` and `tags[]=a&tags[]=b` shapes into the\n# same recursive structure; the Roundhouse runtime mirrors that.\n#\n# `Roundhouse::ParamValue` is the cross-target type contract: each\n# target's runtime defines its own recursive realization (Crystal\n# alias here, TS `type ParamValue = …`, Ruby/Spinel dynamic). The\n# lowerer emits target-agnostic `is_a?(Hash)` / `is_a?(String)`\n# narrowing around accesses; each emit translates `is_a?` to its\n# idiomatic narrowing predicate.\n#\n# Crystal's `alias` admits self-reference through a generic\n# constructor (`Hash`/`Array` here) — the same pattern stdlib's\n# `JSON::Any` uses internally.\n\nmodule Roundhouse\n  alias ParamValue = String | Hash(String, ParamValue) | Array(ParamValue)\nend\n"},{"path":"src/route_helpers.cr","content":"module RouteHelpers\n  def self.root_path : String\n    \"/\"\n  end\n\n  def self.articles_path : String\n    \"/articles\"\n  end\n\n  def self.new_article_path : String\n    \"/articles/new\"\n  end\n\n  def self.article_path(id : Int64) : String\n    \"/articles/#{id}\"\n  end\n\n  def self.edit_article_path(id : Int64) : String\n    \"/articles/#{id}/edit\"\n  end\n\n  def self.article_comments_path(article_id : Int64) : String\n    \"/articles/#{article_id}/comments\"\n  end\n\n  def self.article_comment_path(article_id : Int64, id : Int64) : String\n    \"/articles/#{article_id}/comments/#{id}\"\n  end\nend\n"},{"path":"src/router.cr","content":"# Generated from runtime/ruby/action_dispatch/router.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nmodule ActionDispatch\n  module Router\n    class Route\n      property verb : String\n      property pattern : String\n      property controller : Symbol\n      property action : Symbol\n\n      def initialize(verb : String, pattern : String, controller : Symbol, action : Symbol) : Nil\n        @verb = verb\n        @pattern = pattern\n        @controller = controller\n        @action = action\n      end\n    end\n  end\nend\n\nmodule ActionDispatch\n  module Router\n    class MatchResult\n      property controller : Symbol\n      property action : Symbol\n      property path_params : Hash(String, String)\n\n      def initialize(controller : Symbol, action : Symbol, path_params : Hash(String, String)) : Nil\n        @controller = controller\n        @action = action\n        @path_params = path_params\n      end\n    end\n  end\nend\n\nmodule ActionDispatch\n  module Router\n    def self.match(method : String, path : String, table : Array(ActionDispatch::Router::Route)) : ActionDispatch::Router::MatchResult?\n      method_upcase = method.to_s.upcase\n      i = 0_i64\n      while i < table.size\n        route = table[i]\n        if route.not_nil!.verb.to_s == method_upcase\n          params = match_pattern(route.not_nil!.pattern.to_s, path)\n          if params.nil?\n            nil\n          else\n            return ::ActionDispatch::Router::MatchResult.new(route.not_nil!.controller, route.not_nil!.action, params)\n          end\n        end\n        i += 1_i64\n      end\n      nil\n    end\n\n    def self.match_pattern(pattern : String, path : String) : Hash(String, String)?\n      pattern_parts = pattern.split(\"/\")\n      path_parts = path.split(\"/\")\n      return if pattern_parts.size != path_parts.size\n      params = {} of String => String\n      i = 0_i64\n      while i < pattern_parts.size\n        pp = pattern_parts[i]\n        ap = path_parts[i]\n        if pp.starts_with?(\":\")\n          params[pp[1_i64..]] = ap\n        else\n          return if pp != ap\n        end\n        i += 1_i64\n      end\n      params\n    end\n  end\nend\n"},{"path":"src/routes.cr","content":"module Routes\n  def self.table : Array(ActionDispatch::Router::Route)\n    [::ActionDispatch::Router::Route.new(\"GET\", \"/articles\", :articles, :index), ::ActionDispatch::Router::Route.new(\"GET\", \"/articles/new\", :articles, :new), ::ActionDispatch::Router::Route.new(\"POST\", \"/articles\", :articles, :create), ::ActionDispatch::Router::Route.new(\"GET\", \"/articles/:id\", :articles, :show), ::ActionDispatch::Router::Route.new(\"GET\", \"/articles/:id/edit\", :articles, :edit), ::ActionDispatch::Router::Route.new(\"PATCH\", \"/articles/:id\", :articles, :update), ::ActionDispatch::Router::Route.new(\"DELETE\", \"/articles/:id\", :articles, :destroy), ::ActionDispatch::Router::Route.new(\"POST\", \"/articles/:article_id/comments\", :comments, :create), ::ActionDispatch::Router::Route.new(\"DELETE\", \"/articles/:article_id/comments/:id\", :comments, :destroy)]\n  end\n\n  def self.root : ActionDispatch::Router::Route\n    ::ActionDispatch::Router::Route.new(\"GET\", \"/\", :articles, :index)\n  end\nend\n"},{"path":"src/schema.cr","content":"module Schema\n  def self.statements : Array(String)\n    [\"CREATE TABLE IF NOT EXISTS articles (\\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\\n  body TEXT,\\n  created_at TEXT NOT NULL,\\n  title TEXT,\\n  updated_at TEXT NOT NULL\\n)\", \"CREATE TABLE IF NOT EXISTS comments (\\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\\n  article_id INTEGER NOT NULL,\\n  body TEXT,\\n  commenter TEXT,\\n  created_at TEXT NOT NULL,\\n  updated_at TEXT NOT NULL\\n)\", \"CREATE INDEX IF NOT EXISTS index_comments_on_article_id ON comments (article_id)\"]\n  end\nend\n"},{"path":"src/seeds.cr","content":"module Seeds\n  def self.run : Nil\n    if Article.count.not_nil! > 0_i64\n      nil\n    else\n      article1 = Article.create!(title: \"Getting Started with Rails\", body: \"Rails is a web application framework running on the Ruby programming language. It makes building web apps faster and easier with conventions over configuration.\")\n      Comment.create!(article_id: article1.id, commenter: \"Alice\", body: \"Great introduction! Rails really does make development faster.\")\n      Comment.create!(article_id: article1.id, commenter: \"Bob\", body: \"I love how Rails handles database migrations automatically.\")\n      \n      article2 = Article.create!(title: \"Understanding MVC Architecture\", body: \"MVC stands for Model-View-Controller. Models handle data and business logic, Views display information to users, and Controllers coordinate between them.\")\n      Comment.create!(article_id: article2.id, commenter: \"Carol\", body: \"This pattern really helps keep code organized!\")\n      \n      article3 = Article.create!(title: \"Ruby2JS: Rails Everywhere\", body: \"Ruby2JS transpiles Ruby to JavaScript, enabling Rails applications to run in browsers, on Node.js, and at the edge. Same code, different runtimes.\")\n      \n      puts \"Created #{Article.count.not_nil!} articles and #{Comment.count.not_nil!} comments\"\n    end\n  end\nend\n"},{"path":"src/server.cr","content":"# Roundhouse Crystal server runtime — primitive HTTP listener that\n# dispatches through the transpiled framework runtime.\n#\n# Pipeline mirrors runtime/typescript/server.ts:\n#   1. Parse HTTP request → method, path, body params\n#   2. ActionDispatch::Router.match(method, path, routes_table) →\n#      {controller: Symbol, action: Symbol, path_params: Hash}\n#   3. Look up the controller class in @@controllers, instantiate\n#   4. Set @params (ActionController::Parameters), @session, @flash\n#   5. Invoke controller.process_action(action)\n#   6. Format @body, @status, @location into the HTTP response\n#\n# Controllers extend ActionController::Base (transpiled from\n# runtime/ruby/action_controller/base.rb) and inherit render /\n# redirect_to / head etc. The Roundhouse:: namespace here is reserved\n# for primitive concerns (HTTP, sqlite, websocket); framework concerns\n# live under ActionView/ActionController/ActionDispatch/ActiveRecord\n# from the transpiled runtime.\n\nrequire \"http/server\"\nrequire \"mime\"\nrequire \"uri\"\nrequire \"./db\"\nrequire \"./cable\"\n\nmodule Roundhouse\n  module Server\n    @@layout : Proc(String, String)? = nil\n    # Per-route record shape: `{method:, pattern:, controller:, action:}`.\n    # Matches the RBS record-row type that `ActionDispatch::Router.match`\n    # declares for its `table` parameter\n    # (`Array[{ method: String, pattern: String, controller: Symbol,\n    # action: Symbol }]`). Crystal renders that record as a NamedTuple,\n    # and the lowerer emits route rows via the matching shorthand\n    # literal (`{method: \"GET\", ...}`).\n    alias RouteRow = ActionDispatch::Router::Route\n    @@routes : Array(RouteRow) = [] of RouteRow\n    @@controllers : Hash(Symbol, ActionController::Base.class) = {} of Symbol => ActionController::Base.class\n    @@session : ActionDispatch::Session = ActionDispatch::Session.new\n    # Flash is cookie-backed and per-session (per browser), so parallel\n    # clients never share a flash slot — no cross-request leak racing the\n    # comment specs under fullyParallel. The Flash class owns the show-once\n    # sweep (ActionDispatch::Flash#to_persisted); this server is just the\n    # storage adapter: load via `Flash.new(read_flash_cookie(...))`, persist\n    # `flash.to_persisted` to the rh_flash cookie. Mirrors go/kotlin/swift.\n    FLASH_COOKIE = \"rh_flash\"\n\n    def self.start(\n      schema_sql : String,\n      routes,\n      controllers : Hash(Symbol, ActionController::Base.class),\n      root_route = nil,\n      layout : Proc(String, String)? = nil,\n      db_path : String? = nil,\n      port : Int32? = nil,\n    ) : Nil\n      resolved_path = db_path || \"db/development.sqlite3\"\n      resolved_port = port || (ENV[\"PORT\"]?.try(&.to_i) || 3000)\n\n      Roundhouse::Db.open_production_db(resolved_path, schema_sql)\n      ActiveRecord.adapter = Roundhouse::SqliteAdapter.new\n\n      # Bridge the model-callback broadcast shim (`Broadcasts.append`/\n      # `replace`/`remove`, emitted from `broadcasts_to`) to the live\n      # WebSocket fan-out. Without this the broadcaster stays nil and\n      # `Broadcasts.record` only logs — the create/destroy turbo-stream\n      # never reaches subscribed `<turbo-cable-stream-source>` viewers\n      # (the e2e action_cable spec). `Broadcasts.record` passes the\n      # already-rendered `<turbo-stream>` fragment as the second arg, so\n      # this forwards (stream, fragment) straight to `Cable.dispatch`.\n      Broadcasts.install_broadcaster(\n        ->(stream : String, html : String) { Roundhouse::Cable.broadcast(stream, html) }\n      )\n\n      @@routes = if root_route\n                   [root_route] + routes\n                 else\n                   routes\n                 end\n      @@controllers = controllers\n      @@layout = layout\n\n      server = HTTP::Server.new do |context|\n        dispatch(context)\n      end\n      address = server.bind_tcp(\"127.0.0.1\", resolved_port)\n      puts \"Roundhouse Crystal server listening on http://#{address}\"\n      server.listen\n    end\n\n    def self.dispatch(context : HTTP::Server::Context) : Nil\n      ActionView::ViewHelpers.reset_slots!\n      method = context.request.method.upcase\n      path = context.request.path\n\n      if path == \"/cable\"\n        Roundhouse::Cable.handle(context)\n        return\n      end\n\n      # Static assets: serve `static/<path>` for any `/assets/*` request.\n      # Mirrors Rails' Propshaft URL shape — the importmap pins and\n      # `stylesheet_link_tag(\"tailwind\")` both point at /assets/<name>.\n      # `bin/rh transpile crystal` writes the actual files into\n      # `static/assets/` (Tailwind compile output, turbo.min.js copy).\n      # Returns 404 fall-through if the file isn't present, so route\n      # matching never sees an /assets/ path that's served from disk.\n      if path.starts_with?(\"/assets/\")\n        file = File.join(\"static\", path)\n        if File.file?(file)\n          context.response.headers[\"Content-Type\"] =\n            MIME.from_filename?(file) || \"application/octet-stream\"\n          File.open(file) { |io| IO.copy(io, context.response.output) }\n          return\n        end\n      end\n\n      # Per-request format inference. Strip a `.json` suffix from the\n      # request path before route matching so `/articles/1.json` and\n      # `/articles/1` share one route entry, and remember the format\n      # so the controller's respond_to-flattened branch can pick the\n      # right view + Content-Type. Mirrors `runtime/typescript/server.\n      # ts:149-161` and the Ruby scaffold's `main.rb:82-93` — every\n      # target needs this glue at its server entry point since route\n      # patterns are format-agnostic.\n      request_format = :html\n      route_path = path\n      if route_path.ends_with?(\".json\")\n        request_format = :json\n        route_path = route_path[0, route_path.size - 5]\n      end\n\n      body_params = read_form_body(context.request)\n      # `_method=delete|patch|put` from Rails' hidden form field is\n      # always a top-level (non-nested) key, so it survives bracket-\n      # parsing untouched.\n      if method == \"POST\"\n        raw_method = body_params[\"_method\"]?\n        if raw_method.is_a?(String)\n          upper = raw_method.upcase\n          if upper == \"PATCH\" || upper == \"PUT\" || upper == \"DELETE\"\n            method = upper\n          end\n        end\n      end\n\n      matched = ActionDispatch::Router.match(method, route_path, @@routes)\n      if matched.nil?\n        context.response.status_code = 404\n        context.response.content_type = \"text/plain\"\n        context.response.print \"Not Found: #{method} #{path}\"\n        return\n      end\n\n      # `matched` is now a typed `ActionDispatch::Router::MatchResult`\n      # (was a `Hash[Symbol, untyped]` requiring explicit `.as(T)` per\n      # field). Per-field types are baked into the class definition;\n      # no narrowing or casts needed.\n      ctrl_sym = matched.controller\n      action = matched.action\n      path_params = matched.path_params\n      ctrl_class = @@controllers[ctrl_sym]?\n      if ctrl_class.nil?\n        context.response.status_code = 500\n        context.response.content_type = \"text/plain\"\n        context.response.print \"No controller registered: #{ctrl_sym}\"\n        return\n      end\n\n      # Build merged params: path + query + body. Path captures and\n      # query-string entries are always String leaves; form-body keys\n      # may be bracket-nested (`comment[commenter]`) and surface as\n      # `Hash(String, ParamValue)` sub-trees. The slot's typed value\n      # union `Roundhouse::ParamValue = String | Hash(...) | Array(...)`\n      # accepts either shape; the lowered `<Resource>Params.from_raw`\n      # emit narrows via `is_a?(Hash)` / `is_a?(String)` at access.\n      merged = {} of String => Roundhouse::ParamValue\n      path_params.each { |k, v| merged[k] = v }\n      context.request.query_params.each { |k, v| merged[k] = v }\n      body_params.each { |k, v| merged[k] = v }\n\n      ctrl = ctrl_class.new\n      ctrl.params = merged\n      ctrl.session = @@session\n      # Reload the flash carried from the previous request (the redirect\n      # that set `flash[:notice] = …`) so views render it once.\n      ctrl.flash = ActionDispatch::Flash.new(read_flash_cookie(context.request))\n      ctrl.request_method = method\n      ctrl.request_path = path\n      ctrl.request_format = request_format\n\n      begin\n        ctrl.process_action(action)\n      rescue err : Exception\n        STDERR.puts \"handler error: #{err.message}\"\n        STDERR.puts err.backtrace.join(\"\\n\")\n        context.response.status_code = 500\n        context.response.content_type = \"text/plain\"\n        context.response.print \"Server error: #{err.message}\"\n        return\n      end\n\n      # Persist the swept flash to the rh_flash cookie for the next request.\n      # Flash#to_persisted carries forward only entries this request set\n      # (show-once); on a plain render nothing was set, so the displayed\n      # notice drops out and the cookie clears. Set before any body output —\n      # Set-Cookie is a header (headers flush on first write/finalize).\n      persisted = (ctrl.flash || ActionDispatch::Flash.new).to_persisted\n      context.response.headers[\"Set-Cookie\"] = flash_set_cookie(persisted)\n\n      status = ctrl.status || 200i64\n      body = ctrl.body || \"\"\n      location = ctrl.location\n\n      if !location.nil? && !location.empty?\n        context.response.status_code = status.to_i\n        context.response.headers[\"Location\"] = location\n        return\n      end\n\n      # JSON responses skip the html layout wrap and ship the\n      # controller body verbatim with the controller-supplied\n      # Content-Type. Mirrors `runtime/typescript/server.ts:236-248`\n      # and the Ruby scaffold's `main.rb:157-167` branch — the\n      # controller's `respond_to`-flattened body picks the JSON view\n      # + content_type; the server just honors it.\n      if request_format == :json\n        context.response.status_code = status.to_i\n        context.response.content_type =\n          (ctrl.content_type.empty? ? \"application/json; charset=utf-8\" : ctrl.content_type)\n        context.response.print body\n        return\n      end\n\n      # Layout wrapping: when a layout proc is configured, pass body\n      # to it (mirrors TS's `layout?: (body) => string` shape).\n      response_body = if (l = @@layout)\n                       ActionView::ViewHelpers.set_yield(body)\n                       l.call(body)\n                     else\n                       body\n                     end\n\n      context.response.status_code = status.to_i\n      context.response.content_type = \"text/html; charset=utf-8\"\n      context.response.print response_body\n    end\n\n    # Decode the rh_flash cookie into the String-keyed map `Flash.new`\n    # reloads from. Absent → empty (first request in a session). Only the\n    # closed notice/alert key set; values percent-encoded so the\n    # `key=value&…` structure + cookie-octet rules survive notice text.\n    # Parses the raw Cookie header (no HTTP::Cookie value validation).\n    def self.read_flash_cookie(request : HTTP::Request) : Hash(String, String)\n      result = {} of String => String\n      raw = request.headers[\"Cookie\"]?\n      return result if raw.nil?\n      raw.split(';').each do |jar|\n        trimmed = jar.strip\n        next unless trimmed.starts_with?(\"#{FLASH_COOKIE}=\")\n        val = trimmed[(FLASH_COOKIE.size + 1)..]\n        val.split('&').each do |kv|\n          idx = kv.index('=')\n          next if idx.nil? || idx == 0\n          k = kv[0, idx]\n          next unless k == \"notice\" || k == \"alert\"\n          v = URI.decode_www_form(kv[(idx + 1)..])\n          result[k] = v unless v.empty?\n        end\n      end\n      result\n    end\n\n    # Build the rh_flash Set-Cookie value. Empty → a clearing cookie\n    # (Max-Age=0) so a notice shown once doesn't stick. HttpOnly + Path=/.\n    def self.flash_set_cookie(persisted : Hash(String, String)) : String\n      return \"#{FLASH_COOKIE}=; Path=/; Max-Age=0; HttpOnly\" if persisted.empty?\n      parts = [] of String\n      [\"notice\", \"alert\"].each do |k|\n        if v = persisted[k]?\n          parts << \"#{k}=#{URI.encode_www_form(v)}\"\n        end\n      end\n      \"#{FLASH_COOKIE}=#{parts.join('&')}; Path=/; HttpOnly\"\n    end\n\n    # Parse a `application/x-www-form-urlencoded` body into a nested\n    # `Hash(String, Roundhouse::ParamValue)`. Rails-shape bracket keys\n    # are unwrapped:\n    #\n    #   `comment[commenter]=Sam` → `{\"comment\" => {\"commenter\" => \"Sam\"}}`\n    #   `tags[]=a&tags[]=b`       → `{\"tags\" => [\"a\", \"b\"]}`\n    #   `_method=delete`          → `{\"_method\" => \"delete\"}`\n    #\n    # Bare (no-bracket) keys land as String leaves. Mirrors the\n    # TS server's `parseFormData` + `setNestedParam` (runtime/\n    # typescript/server.ts) and Spinel's `assign_form_pair`\n    # (runtime/spinel/cgi_io.rb).\n    def self.read_form_body(request : HTTP::Request) : Hash(String, Roundhouse::ParamValue)\n      result = {} of String => Roundhouse::ParamValue\n      content_type = request.headers[\"Content-Type\"]? || \"\"\n      return result unless content_type.starts_with?(\"application/x-www-form-urlencoded\")\n      body_io = request.body\n      return result if body_io.nil?\n      raw = body_io.gets_to_end\n      return result if raw.empty?\n      URI::Params.parse(raw) do |k, v|\n        set_nested_param(result, k, v)\n      end\n      result\n    end\n\n\n    # Insert `key=val` into the nested params map, handling Rails'\n    # bracket syntax. Recognized shapes:\n    #\n    #   `parent[child]=v` → `out[parent] = { child => v }`\n    #   `parent[]=v`      → `out[parent] = [..., v]`\n    #\n    # Deeper nesting (`a[b][c]`) is unsupported today — the real-blog\n    # fixture only exercises one level. Future work can extend the\n    # recursion if an app needs it; the ParamValue type admits it.\n    private def self.set_nested_param(\n      into : Hash(String, Roundhouse::ParamValue),\n      key : String,\n      val : String,\n    ) : Nil\n      open_bracket = key.index('[')\n      if open_bracket.nil?\n        into[key] = val\n        return\n      end\n      close_bracket = key.index(']', open_bracket + 1)\n      return if close_bracket.nil?\n      parent = key[0, open_bracket]\n      inner = key[(open_bracket + 1)...close_bracket]\n      if inner.empty?\n        # `tags[]=v` — array append.\n        existing = into[parent]?\n        bucket = if existing.is_a?(Array)\n                   existing\n                 else\n                   [] of Roundhouse::ParamValue\n                 end\n        bucket << val.as(Roundhouse::ParamValue)\n        into[parent] = bucket\n      else\n        # `parent[child]=v` — nested hash.\n        existing = into[parent]?\n        bucket = if existing.is_a?(Hash)\n                   existing\n                 else\n                   {} of String => Roundhouse::ParamValue\n                 end\n        bucket[inner] = val\n        into[parent] = bucket\n      end\n    end\n  end\nend\n"},{"path":"src/session.cr","content":"# Generated from runtime/ruby/action_dispatch/session.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nmodule ActionDispatch\n  class Session\n    @data : Hash(String, String?)\n\n    def initialize(other = nil)\n      @data = {} of String => String??\n      return if other.nil?\n      keys = other.keys\n      i = 0_i64\n      while i < keys.size\n        k = keys[i]\n        v = other[k]\n        @data.not_nil![k.to_s] = v\n        i += 1_i64\n      end\n    end\n\n    def [](key)\n      k = key.to_s\n      return @data[k] if @data.has_key?(k)\n      nil\n    end\n\n    def []=(key, value)\n      @data[key.to_s] = value\n      value\n    end\n\n    def fetch(key, default = nil)\n      k = key.to_s\n      return @data[k] if @data.has_key?(k)\n      default\n    end\n\n    def key?(key)\n      @data.has_key?(key.to_s)\n    end\n\n    def has_key?(key)\n      @data.has_key?(key.to_s)\n    end\n\n    def include?(key)\n      @data.has_key?(key.to_s)\n    end\n\n    def delete(key)\n      @data.delete(key.to_s)\n    end\n\n    def length\n      @data.size\n    end\n\n    def size\n      @data.size\n    end\n\n    def empty? : Bool\n      @data.empty?\n    end\n\n    def keys : Array(String)\n      @data.keys\n    end\n\n    def values\n      @data.values\n    end\n\n    def each\n      keys = @data.keys\n      i = 0_i64\n      while i < keys.size\n        k = keys[i]\n        v = @data[k]\n        yield k, v\n        i += 1_i64\n      end\n      self\n    end\n\n    def to_h\n      @data\n    end\n\n    def merge(other)\n      result = ::ActionDispatch::Session.new(to_h)\n      other.each do |k, v| result[k] = v end\n      result\n    end\n  end\nend\n"},{"path":"src/test_helper.cr","content":"# Test base class for emitted Crystal specs. Provides the per-test\n# isolation harness (`RoundhouseTest.discover`), the\n# ActionDispatch::IntegrationTest surface (get/post/etc., assert_\n# response/redirected_to/select), and the few assertion methods that\n# the inline-assertion lowerer (`src/lower/test_module_to_library/\n# inline_assertions.rs`) deliberately leaves unrewritten — those\n# whose semantics differ enough across targets that uniform inline\n# emit isn't safe.\n#\n# **What's emitted inline as `raise unless …`** (NOT defined here):\n#   assert / assert_not / refute, assert_equal / refute_equal,\n#   assert_nil / refute_nil, assert_empty / refute_empty,\n#   assert_includes / refute_includes, assert_kind_of,\n#   assert_instance_of, assert_predicate / refute_predicate,\n#   assert_raises, assert_difference / assert_no_difference.\n#\n# **Kept here** (cross-target friction at the lowering level):\n#   - `assert_match`: Crystal's `Regex#matches?` requires `String`\n#     (not nilable); the inline lowering's `pat.match?(val)` shape\n#     hits Crystal's strict null-check. Nilable-handling stays in\n#     the helper.\n#   - `assert_operator`: Class-subclass `<` checks are a Ruby/Crystal\n#     idiom with no TS analog; lowering would have to translate per-\n#     target. Kept here in symmetric form across targets.\n#\n# Discovery: each emitted test class invokes `RoundhouseTest.discover`\n# at the bottom of its file. The macro walks the class's instance\n# methods at compile time, generating one `it \"<name>\"` Spec block\n# per `test_*` method. Each `it` instantiates a fresh test object and\n# calls the matching method, mirroring Minitest's per-test isolation.\n\nrequire \"spec\"\n\nabstract class RoundhouseTest\n  # Accepts `String?` so callers can pass nilable values directly\n  # (e.g. `err.message` from a Crystal Exception returns `String?`);\n  # nil fails the assertion the same as a non-matching string.\n  def assert_match(pattern, value : String?, msg : String? = nil) : Nil\n    if value.nil?\n      fail(msg || \"expected non-nil string to match #{pattern.inspect}\")\n    end\n    re = pattern.is_a?(Regex) ? pattern.as(Regex) : Regex.new(pattern.to_s)\n    fail(msg || \"expected #{value.inspect} to match #{re.inspect}\") unless re.matches?(value)\n  end\n\n  # Ruby's `assert_operator a, :op, b` — eval `a.op(b)` and assert truthy.\n  # Symbol-shaped op names (':<', ':>') and the bare form both accepted.\n  # Class-subclass `<` (e.g. `assert_operator A, :<, B` for \"A is a\n  # subclass of B\") works natively in Crystal — class `<` is the\n  # subclass relation.\n  def assert_operator(left, op, right, msg : String? = nil) : Nil\n    op_str = op.to_s.lstrip(':')\n    result = case op_str\n             when \"<\"  then left < right\n             when \">\"  then left > right\n             when \"<=\" then left <= right\n             when \">=\" then left >= right\n             when \"==\" then left == right\n             when \"!=\" then left != right\n             else\n               fail(msg || \"assert_operator: unsupported op #{op}\")\n             end\n    fail(msg || \"expected #{left.inspect} #{op_str} #{right.inspect}\") unless result\n  end\n\n  def flunk(msg : String? = nil) : Nil\n    fail(msg || \"flunked\")\n  end\n\n  def skip(msg : String? = nil) : Nil\n    raise Spec::SpecSkip.new(msg || \"skipped\", file: __FILE__, line: __LINE__)\n  end\n\n  # ── ActionDispatch::IntegrationTest surface ──────────────────────\n  #\n  # In-process dispatch via `Routes.table` + the registered controller\n  # registry. Mirrors `runtime/typescript/minitest.ts:290-371` and\n  # spinel's `dispatch_request` (`runtime/spinel/test/test_helper.rb:\n  # 219-248`). Tests stash status / body / location on the test\n  # instance for subsequent `assert_response` / `assert_select` /\n  # `assert_redirected_to` checks.\n  @__body : String = \"\"\n  @__status : Int64 = 0_i64\n  @__location : String = \"\"\n  @__session : ::ActionDispatch::Session = ::ActionDispatch::Session.new\n  @__flash : ::ActionDispatch::Flash = ::ActionDispatch::Flash.new\n\n  def get(path : String, params = nil) : Nil\n    _ = params\n    dispatch(\"GET\", path, {} of String => Roundhouse::ParamValue)\n  end\n\n  def post(path : String, params = nil) : Nil\n    dispatch(\"POST\", path, normalize_params(params))\n  end\n\n  def put(path : String, params = nil) : Nil\n    dispatch(\"PUT\", path, normalize_params(params))\n  end\n\n  def patch(path : String, params = nil) : Nil\n    dispatch(\"PATCH\", path, normalize_params(params))\n  end\n\n  def delete(path : String, params = nil) : Nil\n    _ = params\n    dispatch(\"DELETE\", path, {} of String => Roundhouse::ParamValue)\n  end\n\n  def head(path : String, params = nil) : Nil\n    _ = params\n    dispatch(\"HEAD\", path, {} of String => Roundhouse::ParamValue)\n  end\n\n  # Test fixtures pass NamedTuple-shape params\n  # (`params: {article: {title: \"…\"}}`); the wire-level request body\n  # is `Hash(String, ParamValue)`. Recursively stringify keys and\n  # narrow Symbol leaves to their String form, matching what the\n  # production form-body parser would produce. Untyped param admits\n  # NamedTuple, Hash, or nil from each call site.\n  private def normalize_params(params) : Hash(String, Roundhouse::ParamValue)\n    out = {} of String => Roundhouse::ParamValue\n    case params\n    when Hash\n      params.each { |k, v| out[k.to_s] = nested_param_value(v) }\n    when NamedTuple\n      params.each { |k, v| out[k.to_s] = nested_param_value(v) }\n    end\n    out\n  end\n\n  private def nested_param_value(v) : Roundhouse::ParamValue\n    case v\n    when String\n      v\n    when Symbol\n      v.to_s\n    when Hash\n      inner = {} of String => Roundhouse::ParamValue\n      v.each { |kk, vv| inner[kk.to_s] = nested_param_value(vv) }\n      inner\n    when Array\n      v.map { |elem| nested_param_value(elem) }.as(Roundhouse::ParamValue)\n    when NamedTuple\n      inner = {} of String => Roundhouse::ParamValue\n      v.each { |kk, vv| inner[kk.to_s] = nested_param_value(vv) }\n      inner\n    else\n      v.to_s\n    end\n  end\n\n  private def dispatch(\n    method : String,\n    path : String,\n    body : Hash(String, Roundhouse::ParamValue),\n  ) : Nil\n    ::ActionView::ViewHelpers.reset_slots!\n    matched = ::ActionDispatch::Router.match(method, path, RoundhouseTest.routes)\n    if matched.nil?\n      fail(\"no route for #{method} #{path}\")\n    end\n    matched = matched.not_nil!\n    ctrl_class = RoundhouseTest.controllers[matched.controller]?\n    if ctrl_class.nil?\n      fail(\"no controller registered for #{matched.controller}\")\n    end\n    merged = {} of String => Roundhouse::ParamValue\n    matched.path_params.each { |k, v| merged[k] = v.as(Roundhouse::ParamValue) }\n    body.each { |k, v| merged[k] = v }\n    ctrl = ctrl_class.not_nil!.new\n    ctrl.params = merged\n    ctrl.session = @__session\n    ctrl.flash = @__flash\n    ctrl.request_method = method\n    ctrl.request_path = path\n    ctrl.process_action(matched.action)\n    @__body = ctrl.body || \"\"\n    @__status = ctrl.status || 200_i64\n    @__location = ctrl.location || \"\"\n    @__flash = ctrl.flash\n  end\n\n  # ── HTTP response assertions ─────────────────────────────────────\n\n  STATUS_SYMBOLS = {\n    success:              200..299,\n    redirect:             300..399,\n    missing:              404,\n    not_found:            404,\n    error:                500..599,\n    ok:                   200,\n    created:              201,\n    accepted:             202,\n    no_content:           204,\n    moved_permanently:    301,\n    found:                302,\n    see_other:            303,\n    not_modified:         304,\n    bad_request:          400,\n    unauthorized:         401,\n    forbidden:            403,\n    unprocessable_entity: 422,\n    # Rails 8.1.x scaffold renamed `:unprocessable_entity` →\n    # `:unprocessable_content` mid-version (HTTP 422 description\n    # churn). Alias both so emit follows whichever the fixture's\n    # scaffold currently produces.\n    unprocessable_content: 422,\n    internal_server_error: 500,\n  }\n\n  def assert_response(expected, msg : String? = nil) : Nil\n    actual = @__status.to_i32\n    matched = case expected\n              when Int\n                expected.to_i32 == actual\n              when Symbol\n                rng = STATUS_SYMBOLS[expected]?\n                case rng\n                when Range then rng.includes?(actual)\n                when Int   then rng.to_i32 == actual\n                else false\n                end\n              else\n                false\n              end\n    return if matched\n    body_preview = @__body[0, 200]? || @__body\n    fail(msg || \"expected response #{expected.inspect}, got status=#{actual} body=#{body_preview.inspect}\")\n  end\n\n  def assert_redirected_to(expected_path : String, msg : String? = nil) : Nil\n    if @__status < 300 || @__status >= 400\n      fail(msg || \"expected a redirect, got status=#{@__status} location=#{@__location.inspect}\")\n    end\n    return if @__location.includes?(expected_path)\n    fail(msg || \"expected Location to contain #{expected_path.inspect}, got #{@__location.inspect}\")\n  end\n\n  # `assert_select` substring-matches on the opening tag or\n  # id=\"x\" / class=\"x\"-style fragment derived from the selector.\n  # Rough but effective for the scaffold-blog HTML shapes —\n  # bodies like `\"#articles\"`, `\".p-4\"`, `\"h1\"`. Block form\n  # additionally yields so nested `assert_select`s further narrow\n  # within the matched section; we don't shrink the body here, so\n  # nested checks still see the full response body — same loose\n  # semantic as the TS shim.\n  def assert_select(selector : String, content : String? = nil, msg : String? = nil) : Nil\n    fragment = selector_fragment(selector)\n    unless @__body.includes?(fragment)\n      fail(msg || \"expected body to match selector #{selector.inspect} (looked for #{fragment.inspect})\")\n      return\n    end\n    if !content.nil? && !@__body.includes?(content)\n      fail(msg || \"expected body to contain #{content.inspect} matching selector #{selector.inspect}\")\n    end\n  end\n\n  # Kwarg form — `assert_select(\"h2\", minimum: 1, maximum: 5)` — Rails\n  # passes `minimum:` / `maximum:` / `count:` for cardinality checks.\n  # The substring-match shim treats these as best-effort no-ops; the\n  # selector-presence check below is sufficient for the scaffold-blog\n  # shapes.\n  def assert_select(selector : String, **opts) : Nil\n    _ = opts\n    assert_select(selector)\n  end\n\n  def assert_select(selector : String, **opts, &block) : Nil\n    _ = opts\n    assert_select(selector)\n    yield\n  end\n\n  def assert_select(selector : String, &block) : Nil\n    assert_select(selector)\n    yield\n  end\n\n  private def selector_fragment(selector : String) : String\n    first = selector.split(/\\s+/).first\n    case first\n    when .starts_with?(\"#\") then %(id=\"#{first[1..]}\")\n    when .starts_with?(\".\") then %(#{first[1..]}\")\n    else                         \"<#{first}\"\n    end\n  end\n\n  # ── per-test registry + reset hooks ──────────────────────────────\n  #\n  # Routes table, controller registry, fixture loaders, and the\n  # schema reset SQL live as class state on `RoundhouseTest`. The\n  # emitted `src/test_setup.cr` registers them at process-init time;\n  # the per-test `before_each` (installed by the `inherited` macro\n  # below) resets the in-memory DB and re-runs each fixture loader\n  # so every spec starts from a clean state.\n\n  alias RouteRow = ::ActionDispatch::Router::Route\n\n  @@routes : Array(RouteRow) = [] of RouteRow\n  @@controllers : Hash(Symbol, ::ActionController::Base.class) =\n    {} of Symbol => ::ActionController::Base.class\n  @@fixture_loaders : Array(-> Nil) = [] of -> Nil\n  @@schema_sql : String = \"\"\n\n  def self.routes : Array(RouteRow)\n    @@routes\n  end\n\n  def self.routes=(value : Array(RouteRow)) : Array(RouteRow)\n    @@routes = value\n  end\n\n  def self.controllers : Hash(Symbol, ::ActionController::Base.class)\n    @@controllers\n  end\n\n  def self.controllers=(value : Hash(Symbol, ::ActionController::Base.class)) : Hash(Symbol, ::ActionController::Base.class)\n    @@controllers = value\n  end\n\n  def self.fixture_loaders : Array(-> Nil)\n    @@fixture_loaders\n  end\n\n  def self.fixture_loaders=(value : Array(-> Nil)) : Array(-> Nil)\n    @@fixture_loaders = value\n  end\n\n  def self.schema_sql : String\n    @@schema_sql\n  end\n\n  def self.schema_sql=(value : String) : String\n    @@schema_sql = value\n  end\n\n  # Reset in-memory DB to a fresh schema and reload every registered\n  # fixture set. Called from the `before_each` block in the discover\n  # macro so each spec sees the canonical starting state.\n  #\n  # No-ops cleanly when the app carries no schema / no fixtures —\n  # framework-test harnesses (router_test, view_helpers_test, etc.)\n  # exercise the runtime layer directly without a Rails-shape app\n  # underneath, so their `src/test_setup.cr` skips the schema and\n  # fixture registrations.\n  def self.reset_and_load_fixtures : Nil\n    return if @@schema_sql.empty?\n    Roundhouse::Db.setup_test_db(@@schema_sql)\n    ::ActiveRecord.adapter = Roundhouse::SqliteAdapter.new\n    @@fixture_loaders.each(&.call)\n  end\n\n  # Bridge the assertion failure into Spec's expectation channel —\n  # Spec catches `Spec::AssertionFailed` and reports it as a failed `it`.\n  private def fail(msg : String) : Nil\n    raise Spec::AssertionFailed.new(msg, file: __FILE__, line: __LINE__)\n  end\n\n  # ── discovery macro ──────────────────────────────────────────────\n  #\n  # Generate `describe <Klass> do … it \"test_X\" do <Klass>.new.test_X; end … end`\n  # at the bottom of the test file. Walks the class's own instance\n  # methods at compile time; each `test_*` method becomes one spec.\n  # Crystal's `spec` autorun fires when `require \"spec\"` is loaded and\n  # the program reaches main, so the test_helper itself doesn't need\n  # an explicit runner.\n  #\n  # `before_each` wraps every `it` with the DB-reset + fixture-reload\n  # so specs start from the canonical state.\n  macro inherited\n    macro finished\n      describe \\{{ @type }} do\n        before_each do\n          RoundhouseTest.reset_and_load_fixtures\n        end\n        \\{% for m in @type.methods %}\n          \\{% if m.name.starts_with?(\"test_\") %}\n            it \\{{ m.name.stringify }} do\n              \\{{ @type }}.new.\\{{ m.name.id }}\n            end\n          \\{% end %}\n        \\{% end %}\n      end\n    end\n  end\nend\n"},{"path":"src/test_setup.cr","content":"# Generated by Roundhouse — Crystal test setup.\n#\n# Registers schema, routes, controllers, and fixture loaders\n# with `RoundhouseTest` class state at program load. Specs\n# pull this in via `app.cr`'s alphabetical sweep; main.cr's\n# production entrypoint doesn't reference this state.\n#\n# Each registration is gated on the corresponding artifact\n# existing. Framework-only test harnesses (router_test,\n# view_helpers_test, etc.) carry test_modules but no\n# schema/routes/controllers — emitting those references\n# unconditionally would trip `undefined constant Schema`.\n\nRoundhouseTest.schema_sql = Schema.statements.join(\";\\n\")\nRoundhouseTest.routes = Routes.table\nRoundhouseTest.controllers = {\n  :application => ApplicationController,\n  :articles => ArticlesController,\n  :comments => CommentsController,\n} of Symbol => ::ActionController::Base.class\nRoundhouseTest.fixture_loaders = [\n  -> { ArticlesFixtures._fixtures_load!; nil },\n  -> { CommentsFixtures._fixtures_load!; nil },\n] of -> Nil\n"},{"path":"src/test_support.cr","content":"# Roundhouse Crystal test-support runtime.\n#\n# Hand-written, shipped alongside generated code (copied in by the\n# Crystal emitter as `src/test_support.cr`). Controller specs call\n# into `TestClient` for HTTP dispatch (pure in-process — no real\n# server) and the returned `TestResponse` for Rails-compatible\n# assertions (`assert_ok`, `assert_redirected_to`, `assert_select`).\n#\n# Mirrors `runtime/typescript/test_support.ts` and\n# `runtime/rust/test_support.rs` in intent, shape, and assertion\n# semantics — substring-match on the response body, loose-but-\n# reliable for the scaffold blog's HTML. A later phase can swap in\n# a real HTML parser (Crystal's XML::Node) by touching only this\n# file; emitted spec bodies are insulated via method contracts.\n\nrequire \"./http\"\n\nmodule Roundhouse\n  module TestSupport\n    # Pure-Crystal test client — dispatches through Router.match,\n    # calls the resolved handler, wraps the response. No real HTTP,\n    # no event-loop glue. Fast + leak-free across specs.\n    class TestClient\n      def get(path : String) : TestResponse\n        dispatch(\"GET\", path, {} of String => String)\n      end\n\n      def post(path : String, body : Hash(String, String) = {} of String => String) : TestResponse\n        dispatch(\"POST\", path, body)\n      end\n\n      def patch(path : String, body : Hash(String, String) = {} of String => String) : TestResponse\n        dispatch(\"PATCH\", path, body)\n      end\n\n      def delete(path : String) : TestResponse\n        dispatch(\"DELETE\", path, {} of String => String)\n      end\n\n      private def dispatch(method : String, path : String, body : Hash(String, String)) : TestResponse\n        result = Roundhouse::Http::Router.match(method, path)\n        raise \"no route for #{method} #{path}\" if result.nil?\n        handler, path_params = result\n        merged = path_params.merge(body)\n        response = handler.call(Roundhouse::Http::ActionContext.new(merged))\n        TestResponse.new(response)\n      end\n    end\n\n    # Wrapper around `ActionResponse` exposing assertion helpers.\n    # Method names mirror Rails' Minitest HTTP assertions; bodies\n    # substring-match for `assert_select`-style queries.\n    class TestResponse\n      getter body : String\n      getter status : Int32\n      getter location : String\n\n      def initialize(raw : Roundhouse::Http::ActionResponse)\n        @body = raw.body\n        @status = raw.status\n        @location = raw.location\n      end\n\n      # `assert_response :success` — status 200 OK.\n      def assert_ok : Nil\n        raise \"expected 200 OK, got #{@status}\" unless @status == 200\n      end\n\n      # `assert_response :unprocessable_entity` — status 422.\n      def assert_unprocessable : Nil\n        raise \"expected 422 Unprocessable Entity, got #{@status}\" unless @status == 422\n      end\n\n      # `assert_response <code>`.\n      def assert_status(code : Int32) : Nil\n        raise \"expected status #{code}, got #{@status}\" unless @status == code\n      end\n\n      # `assert_redirected_to <path>` — status is 3xx and Location\n      # substring-matches the expected path. Loose to tolerate\n      # absolute-vs-relative URL differences.\n      def assert_redirected_to(path : String) : Nil\n        raise \"expected a redirection, got #{@status}\" unless @status >= 300 && @status < 400\n        unless @location.includes?(path)\n          raise \"expected Location to contain #{path.inspect}, got #{@location.inspect}\"\n        end\n      end\n\n      # `assert_select <selector>` — body contains a match for the\n      # selector. Substring-matches on the opening tag or\n      # `id=`/`class=` fragment. Covers the scaffold blog shapes:\n      #   \"h1\"        → contains \"<h1\"\n      #   \"#articles\" → contains `id=\"articles\"`\n      #   \".p-4\"      → contains `p-4\"`\n      #   \"form\"      → contains \"<form\"\n      def assert_select(selector : String) : Nil\n        fragment = TestSupport.selector_fragment(selector)\n        unless @body.includes?(fragment)\n          raise \"expected body to match selector #{selector.inspect} (looked for #{fragment.inspect})\"\n        end\n      end\n\n      # `assert_select <selector>, <text>` — selector check + body\n      # also contains the text.\n      def assert_select_text(selector : String, text : String) : Nil\n        assert_select(selector)\n        unless @body.includes?(text)\n          raise \"expected body to contain text #{text.inspect} under selector #{selector.inspect}\"\n        end\n      end\n\n      # `assert_select <selector>, minimum: N` — at least `n`\n      # occurrences of the selector fragment.\n      def assert_select_min(selector : String, n : Int32) : Nil\n        fragment = TestSupport.selector_fragment(selector)\n        count = 0\n        from = 0\n        while (i = @body.index(fragment, from))\n          count += 1\n          from = i + fragment.size\n        end\n        if count < n\n          raise \"expected at least #{n} matches for selector #{selector.inspect}, got #{count}\"\n        end\n      end\n    end\n\n    # Loose selector → substring fragment. Same rules as the Rust\n    # and TS twins.\n    def self.selector_fragment(selector : String) : String\n      first = selector.split.first? || \"\"\n      case first[0]?\n      when '#'\n        %(id=\"#{first[1..]}\")\n      when '.'\n        %(#{first[1..]}\")\n      else\n        \"<#{first}\"\n      end\n    end\n  end\nend\n"},{"path":"src/view_helpers.cr","content":"# Generated from runtime/ruby/action_view/view_helpers.rb at app emit time.\n# Do not edit by hand — edit the source `.rb` and re-run emit.\n\nrequire \"html\"\n\nHTML_ESCAPES = { \"&\" => \"&amp;\", \"<\" => \"&lt;\", \">\" => \"&gt;\", \"\\\"\" => \"&quot;\", \"'\" => \"&#39;\" }\nHTML_ESCAPE_PATTERN = /[&<>\"']/\n\nmodule ActionView\n  module ViewHelpers\n    @@slots : Hash(Symbol, String) = {} of Symbol => String\n\n    def self.reset_slots! : Nil\n      @@slots = {} of Symbol => String\n    end\n\n    def self.content_for_set(slot : Symbol, value : String) : Nil\n      @@slots[slot] = value\n      nil\n    end\n\n    def self.content_for_get(slot : Symbol) : String?\n      @@slots[slot]?\n    end\n\n    def self.get_slot(slot : Symbol) : String\n      @@slots[slot]? || \"\"\n    end\n\n    def self.get_yield : String\n      @@slots[:__body__]? || \"\"\n    end\n\n    def self.set_yield(content : String) : Nil\n      @@slots[:__body__] = content\n      nil\n    end\n\n    def self.html_escape(s : String) : String\n      HTML.escape(s)\n    end\n\n    def self.truncate(s : String, length : Int64 = 30_i64, omission : String = \"...\") : String\n      return s if s.size <= length\n      cutoff = length - omission.size\n      cutoff = 0_i64 if cutoff < 0_i64\n      \"#{s[0_i64, cutoff]}#{omission}\"\n    end\n\n    def self.dom_id(record : ActiveRecord::Base, suffix : Symbol? = nil) : String\n      if suffix.nil?\n        \"#{record.dom_prefix}_#{record.id}\"\n      else\n        \"#{suffix}_#{record.dom_prefix}_#{record.id}\"\n      end\n    end\n\n    def self.link_to(text, href, opts = {} of String => String)\n      attrs = render_attrs({:href => href}.merge(opts.to_h))\n      \"<a#{attrs}>#{html_escape(text)}</a>\"\n    end\n\n    def self.button_to(text, href, opts = {} of String => String)\n      method = opts[:method]?\n      form_class = opts[:form_class]?\n      inner_opts = opts.to_h.dup\n      inner_opts.delete(:method)\n      inner_opts.delete(:form_class)\n      form_attrs = {action: href, method: \"post\"}.to_h\n      form_attrs[:class] = (form_class || \"button_to\").to_s\n      button_attrs = render_attrs({:type => \"submit\"}.merge(inner_opts))\n      method_input = if !(method.nil?) && method.to_s != \"post\"\n        \"<input type=\\\"hidden\\\" name=\\\"_method\\\" value=\\\"#{method}\\\">\"\n      else\n        \"\"\n      end\n      auth_token_input = \"<input type=\\\"hidden\\\" name=\\\"authenticity_token\\\" value=\\\"\\\">\"\n      \"<form#{render_attrs(form_attrs)}>#{method_input}<button#{button_attrs}>#{html_escape(text)}</button>#{auth_token_input}</form>\"\n    end\n\n    def self.csrf_meta_tags : String\n      \"<meta name=\\\"csrf-param\\\" content=\\\"authenticity_token\\\" />\\n<meta name=\\\"csrf-token\\\" content=\\\"\\\" />\"\n    end\n\n    def self.csp_meta_tag : String\n      \"\"\n    end\n\n    def self.stylesheet_link_tag(name, opts = {} of String => String)\n      href = \"/assets/#{name}.css\"\n      attrs = render_attrs({:rel => \"stylesheet\", :href => href}.merge(opts.to_h))\n      \"<link#{attrs}>\"\n    end\n\n    def self.javascript_importmap_tags(pins : Array(NamedTuple(name: String, path: String))? = nil, entry : String = \"application\") : String\n      if pins.nil? || pins.empty?\n        json = \"{\\n  \\\"imports\\\": {\\n    \\\"@hotwired/turbo\\\": \\\"/assets/turbo.min.js\\\"\\n  }\\n}\"\n        return \"<script type=\\\"importmap\\\" data-turbo-track=\\\"reload\\\">\" + json + \"</script>\" + \"\\n\" + \"<link rel=\\\"modulepreload\\\" href=\\\"/assets/turbo.min.js\\\">\" + \"\\n\" + \"<script type=\\\"module\\\">import \\\"@hotwired/turbo\\\"</script>\"\n      end\n      import_lines = pins.map { |p| \"    \\\"#{p[:name]}\\\": \\\"#{p[:path]}\\\"\" }.join(\",\\n\")\n      json = \"{\\n  \\\"imports\\\": {\\n\" + import_lines + \"\\n  }\\n}\"\n      parts = [] of String\n      parts << \"<script type=\\\"importmap\\\" data-turbo-track=\\\"reload\\\">\" + json + \"</script>\"\n      pins.each do |p| parts << \"<link rel=\\\"modulepreload\\\" href=\\\"#{p[:path]}\\\">\" end\n      parts << \"<script type=\\\"module\\\">import \\\"#{entry}\\\"</script>\"\n      parts.join(\"\\n\")\n    end\n\n    def self.turbo_stream_from(stream)\n      encoded = Base64.strict_encode(stream.to_json)\n      \"<turbo-cable-stream-source channel=\\\"Turbo::StreamsChannel\\\" signed-stream-name=\\\"#{encoded}--unsigned\\\"></turbo-cable-stream-source>\"\n    end\n\n    def self.csrf_token_hidden_input : String\n      \"<input type=\\\"hidden\\\" name=\\\"authenticity_token\\\" value=\\\"\\\">\"\n    end\n\n    def self.method_override_input(method : Symbol) : String\n      method_str = method.to_s\n      if method_str == \"get\" || method_str == \"post\"\n        \"\"\n      else\n        \"<input type=\\\"hidden\\\" name=\\\"_method\\\" value=\\\"#{method_str}\\\">\"\n      end\n    end\n\n    def self.optional_value_attr(value)\n      if value.nil? || value.to_s.empty?\n        \"\"\n      else\n        \" value=\\\"#{html_escape(value.to_s)}\\\"\"\n      end\n    end\n\n    def self.escape_or_empty(value)\n      if value.nil?\n        \"\"\n      else\n        html_escape(value.to_s)\n      end\n    end\n\n    def self.render_attrs(attrs)\n      return \"\" if attrs.empty?\n      pairs = [] of String\n      attrs.each do |k, v|\n        next if v.nil?\n        name = k.to_s\n        if (v.is_a?(Hash) || v.is_a?(NamedTuple))\n          v.each do |inner_k, inner_v|\n            next if inner_v.nil?\n            inner_name = inner_k.to_s.tr(\"_\", \"-\")\n            pairs << \" #{name}-#{inner_name}=\\\"#{html_escape(inner_v.to_s)}\\\"\"\n          end\n        else\n          pairs << \" #{name}=\\\"#{html_escape(v.to_s)}\\\"\"\n        end\n      end\n      pairs.join\n    end\n  end\nend\n"},{"path":"src/views/articles/_article.cr","content":"module Views\n  module Articles\n    def self.article(article : Article, notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      io << \"<div id=\\\"\"\n      io << ::ActionView::ViewHelpers.dom_id(article)\n      io << \"\\\" class=\\\"flex flex-col sm:flex-row justify-between items-center pb-5 sm:pb-0\\\">\\n  <div class=\\\"p-4 border rounded mb-4 flex-grow\\\">\\n    <h2 class=\\\"text-xl font-bold\\\">\\n      \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_path(article.id.not_nil!))}\\\" class=\\\"#{\"text-blue-600 hover:underline\"}\\\">#{::ActionView::ViewHelpers.html_escape(article.title.not_nil!)}</a>\"\n      io << \"\\n      <span id=\\\"\"\n      io << ::ActionView::ViewHelpers.dom_id(article, :comments_count)\n      io << \"\\\" class=\\\"text-gray-500 text-sm font-normal ml-2\\\">\\n        (\"\n      io << Inflector.pluralize(article.comments.size, \"comment\")\n      io << \")\\n      </span>\\n    </h2>\\n    <p class=\\\"text-gray-700 mt-2\\\">\"\n      io << ::ActionView::ViewHelpers.html_escape(::ActionView::ViewHelpers.truncate(article.body.not_nil!, length: 100_i64))\n      io << \"</p>\\n  </div>\\n  <div class=\\\"w-full sm:w-auto flex flex-col sm:flex-row space-x-2 space-y-2\\\">\\n    \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_path(article.id.not_nil!))}\\\" class=\\\"#{\"w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\"}\\\">#{\"Show\"}</a>\"\n      io << \"\\n    \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.edit_article_path(article.id.not_nil!))}\\\" class=\\\"#{\"w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\"}\\\">#{\"Edit\"}</a>\"\n      io << \"\\n    \"\n      io << \"<form action=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_path(article.id.not_nil!))}\\\" method=\\\"post\\\" class=\\\"#{\"button_to\"}\\\">#{::ActionView::ViewHelpers.method_override_input(:delete)}<button type=\\\"submit\\\" class=\\\"#{\"w-full sm:w-auto rounded-md px-3.5 py-2.5 text-white bg-red-600 hover:bg-red-500 font-medium cursor-pointer\"}\\\" data-turbo-confirm=\\\"#{\"Are you sure?\"}\\\">#{\"Destroy\"}</button>#{::ActionView::ViewHelpers.csrf_token_hidden_input.not_nil!}</form>\"\n      io << \"\\n  </div>\\n</div>\\n\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/articles/_article_json.cr","content":"module Views\n  module Articles\n    def self.article_json(article : Article) : String\n      io = String::Builder.new\n      io << \"{\"\n      io << \"\\\"id\\\":\"\n      io << JsonBuilder.encode_value(article.id.not_nil!)\n      io << \",\"\n      io << \"\\\"title\\\":\"\n      io << JsonBuilder.encode_value(article.title.not_nil!)\n      io << \",\"\n      io << \"\\\"body\\\":\"\n      io << JsonBuilder.encode_value(article.body.not_nil!)\n      io << \",\"\n      io << \"\\\"created_at\\\":\"\n      io << JsonBuilder.encode_datetime(article.created_at.not_nil!)\n      io << \",\"\n      io << \"\\\"updated_at\\\":\"\n      io << JsonBuilder.encode_datetime(article.updated_at.not_nil!)\n      io << \",\"\n      io << \"\\\"url\\\":\"\n      io << JsonBuilder.encode_value(RouteHelpers.article_path(article.id.not_nil!) + \".json\")\n      io << \"}\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/articles/_form.cr","content":"module Views\n  module Articles\n    def self.form(article : Article, notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      form_method = if article.persisted?.not_nil!\n        :patch\n      else\n        :post\n      end\n      io << \"<form action=\\\"#{::ActionView::ViewHelpers.html_escape(if article.persisted?.not_nil!\n        RouteHelpers.article_path(article.id.not_nil!)\n      else\n        RouteHelpers.articles_path.not_nil!\n      end)}\\\" accept-charset=\\\"UTF-8\\\" method=\\\"post\\\" class=\\\"#{\"contents\"}\\\">\"\n      io << ::ActionView::ViewHelpers.method_override_input(form_method)\n      io << ::ActionView::ViewHelpers.csrf_token_hidden_input.not_nil!\n      io << \"\\n\"\n      if ! article.errors.empty?\n        io << \"    <div id=\\\"error_explanation\\\" class=\\\"bg-red-50 text-red-500 px-3 py-2 font-medium rounded-md mt-3\\\">\\n      <h2>\"\n        io << Inflector.pluralize(article.errors.size, \"error\")\n        io << \" prohibited this article from being saved:</h2>\\n\\n      <ul class=\\\"list-disc ml-6\\\">\\n        \"\n        article.errors.each do |error|\n          io << \"\\n          <li>\"\n          io << ::ActionView::ViewHelpers.html_escape(error)\n          io << \"</li>\\n        \"\n        end\n        io << \"\\n      </ul>\\n    </div>\\n\"\n      end\n      io << \"\\n  <div class=\\\"my-5\\\">\\n    \"\n      io << \"<label for=\\\"article_title\\\">Title</label>\"\n      io << \"\\n    \"\n      io << \"<input type=\\\"text\\\" name=\\\"article[title]\\\" id=\\\"article_title\\\"#{::ActionView::ViewHelpers.optional_value_attr(article[:title])} class=\\\"#{\"block shadow-sm rounded-md border px-3 py-2 mt-2 w-full border-gray-400 focus:outline-blue-600\"}\\\">\"\n      io << \"\\n  </div>\\n\\n  <div class=\\\"my-5\\\">\\n    \"\n      io << \"<label for=\\\"article_body\\\">Body</label>\"\n      io << \"\\n    \"\n      io << \"<textarea name=\\\"article[body]\\\" id=\\\"article_body\\\" rows=\\\"#{::ActionView::ViewHelpers.html_escape(4_i64.to_s)}\\\" class=\\\"#{\"block shadow-sm rounded-md border px-3 py-2 mt-2 w-full border-gray-400 focus:outline-blue-600\"}\\\">#{::ActionView::ViewHelpers.escape_or_empty(article[:body])}</textarea>\"\n      io << \"\\n  </div>\\n\\n  <div class=\\\"inline\\\">\\n    \"\n      io << \"<input type=\\\"submit\\\" name=\\\"commit\\\" value=\\\"#{::ActionView::ViewHelpers.html_escape(if form_method == :patch\n        \"Update Article\"\n      else\n        \"Create Article\"\n      end)}\\\" data-disable-with=\\\"#{::ActionView::ViewHelpers.html_escape(if form_method == :patch\n        \"Update Article\"\n      else\n        \"Create Article\"\n      end)}\\\" class=\\\"#{\"w-full sm:w-auto rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white inline-block font-medium cursor-pointer\"}\\\">\"\n      io << \"\\n  </div>\\n\"\n      io << \"</form>\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/articles/edit.cr","content":"module Views\n  module Articles\n    def self.edit(article : Article, notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      ::ActionView::ViewHelpers.content_for_set(:title, \"Editing article\")\n      io << \"\\n<div class=\\\"md:w-2/3 w-full\\\">\\n  <h1 class=\\\"font-bold text-4xl\\\">Editing article</h1>\\n\\n  \"\n      io << Views::Articles.form(article)\n      io << \"\\n\\n  \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_path(article.id.not_nil!))}\\\" class=\\\"#{\"w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\"}\\\">#{\"Show this article\"}</a>\"\n      io << \"\\n  \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.articles_path.not_nil!)}\\\" class=\\\"#{\"w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\"}\\\">#{\"Back to articles\"}</a>\"\n      io << \"\\n</div>\\n\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/articles/index.cr","content":"module Views\n  module Articles\n    def self.index(articles : Array(Article), notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      io << ::ActionView::ViewHelpers.turbo_stream_from(\"articles\")\n      io << \"\\n\\n\"\n      ::ActionView::ViewHelpers.content_for_set(:title, \"Articles\")\n      io << \"\\n<div class=\\\"w-full\\\">\\n\"\n      if ! notice.nil? && ! notice.empty?\n        io << \"    <p class=\\\"py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-md inline-block\\\" id=\\\"notice\\\">\"\n        io << ::ActionView::ViewHelpers.html_escape(notice)\n        io << \"</p>\\n\"\n      end\n      io << \"\\n  <div class=\\\"flex justify-between items-center\\\">\\n    <h1 class=\\\"font-bold text-4xl\\\">Articles</h1>\\n    \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.new_article_path.not_nil!)}\\\" class=\\\"#{\"rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white block font-medium\"}\\\">#{\"New article\"}</a>\"\n      io << \"\\n  </div>\\n\\n  <div id=\\\"articles\\\" class=\\\"min-w-full divide-y divide-gray-200 space-y-5\\\">\\n\"\n      if ! articles.empty?\n        io << \"      \"\n        articles.each { |a| io << Views::Articles.article(a) }\n        io << \"\\n\"\n      else\n        io << \"      <p class=\\\"text-center my-10\\\">No articles found.</p>\\n\"\n      end\n      io << \"  </div>\\n</div>\\n\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/articles/index_json.cr","content":"module Views\n  module Articles\n    def self.index_json(articles : Array(Article)) : String\n      io = String::Builder.new\n      io << \"[\"\n      io << articles.map { |article| Views::Articles.article_json(article) }.join(\",\")\n      io << \"]\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/articles/new.cr","content":"module Views\n  module Articles\n    def self.new(article : Article, notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      ::ActionView::ViewHelpers.content_for_set(:title, \"New article\")\n      io << \"\\n<div class=\\\"md:w-2/3 w-full\\\">\\n  <h1 class=\\\"font-bold text-4xl\\\">New article</h1>\\n\\n  \"\n      io << Views::Articles.form(article)\n      io << \"\\n\\n  \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.articles_path.not_nil!)}\\\" class=\\\"#{\"w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\"}\\\">#{\"Back to articles\"}</a>\"\n      io << \"\\n</div>\\n\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/articles/show.cr","content":"module Views\n  module Articles\n    def self.show(article : Article, notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      ::ActionView::ViewHelpers.content_for_set(:title, \"Showing article\")\n      io << \"\\n<div class=\\\"md:w-2/3 w-full\\\">\\n\"\n      if ! notice.nil? && ! notice.empty?\n        io << \"    <p class=\\\"py-2 px-3 bg-green-50 mb-5 text-green-500 font-medium rounded-md inline-block\\\" id=\\\"notice\\\">\"\n        io << ::ActionView::ViewHelpers.html_escape(notice)\n        io << \"</p>\\n\"\n      end\n      io << \"\\n  <h1 class=\\\"font-bold text-4xl\\\">\"\n      io << ::ActionView::ViewHelpers.html_escape(article.title.not_nil!)\n      io << \"</h1>\\n\\n  <div class=\\\"my-4\\\">\\n    <p class=\\\"text-gray-700\\\">\"\n      io << ::ActionView::ViewHelpers.html_escape(article.body.not_nil!)\n      io << \"</p>\\n  </div>\\n\\n  \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.edit_article_path(article.id.not_nil!))}\\\" class=\\\"#{\"w-full sm:w-auto text-center rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\"}\\\">#{\"Edit this article\"}</a>\"\n      io << \"\\n  \"\n      io << \"<a href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.articles_path.not_nil!)}\\\" class=\\\"#{\"w-full sm:w-auto text-center mt-2 sm:mt-0 sm:ml-2 rounded-md px-3.5 py-2.5 bg-gray-100 hover:bg-gray-50 inline-block font-medium\"}\\\">#{\"Back to articles\"}</a>\"\n      io << \"\\n  \"\n      io << \"<form action=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_path(article.id.not_nil!))}\\\" method=\\\"post\\\" class=\\\"#{\"sm:inline-block mt-2 sm:mt-0 sm:ml-2\"}\\\">#{::ActionView::ViewHelpers.method_override_input(:delete)}<button type=\\\"submit\\\" class=\\\"#{\"w-full rounded-md px-3.5 py-2.5 text-white bg-red-600 hover:bg-red-500 font-medium cursor-pointer\"}\\\" data-turbo-confirm=\\\"#{\"Are you sure?\"}\\\">#{\"Destroy this article\"}</button>#{::ActionView::ViewHelpers.csrf_token_hidden_input.not_nil!}</form>\"\n      io << \"\\n</div>\\n\\n<hr class=\\\"my-8\\\">\\n\\n<h2 class=\\\"text-xl font-bold mb-4\\\">Comments</h2>\\n\\n\"\n      io << ::ActionView::ViewHelpers.turbo_stream_from(\"article_#{article.id.not_nil!}_comments\")\n      io << \"\\n\\n<div id=\\\"comments\\\" class=\\\"space-y-4 mb-8\\\">\\n  \"\n      article.comments.each { |c| io << Views::Comments.comment(c) }\n      io << \"\\n</div>\\n\\n<h3 class=\\\"text-lg font-semibold mb-2\\\">Add a Comment</h3>\\n\\n\"\n      form_record = Comment.new\n      form_method = :post\n      io << \"<form action=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_comments_path(article.id.not_nil!))}\\\" accept-charset=\\\"UTF-8\\\" method=\\\"post\\\" class=\\\"#{\"space-y-4\"}\\\">\"\n      io << ::ActionView::ViewHelpers.method_override_input(form_method)\n      io << ::ActionView::ViewHelpers.csrf_token_hidden_input.not_nil!\n      io << \"\\n  <div>\\n    \"\n      io << \"<label for=\\\"comment_commenter\\\" class=\\\"#{\"block font-medium\"}\\\">Commenter</label>\"\n      io << \"\\n    \"\n      io << \"<input type=\\\"text\\\" name=\\\"comment[commenter]\\\" id=\\\"comment_commenter\\\"#{::ActionView::ViewHelpers.optional_value_attr(form_record[:commenter])} class=\\\"#{\"block w-full border rounded p-2\"}\\\">\"\n      io << \"\\n  </div>\\n  <div>\\n    \"\n      io << \"<label for=\\\"comment_body\\\" class=\\\"#{\"block font-medium\"}\\\">Body</label>\"\n      io << \"\\n    \"\n      io << \"<textarea name=\\\"comment[body]\\\" id=\\\"comment_body\\\" rows=\\\"#{::ActionView::ViewHelpers.html_escape(3_i64.to_s)}\\\" class=\\\"#{\"block w-full border rounded p-2\"}\\\">#{::ActionView::ViewHelpers.escape_or_empty(form_record[:body])}</textarea>\"\n      io << \"\\n  </div>\\n  \"\n      io << \"<input type=\\\"submit\\\" name=\\\"commit\\\" value=\\\"#{\"Add Comment\"}\\\" data-disable-with=\\\"#{\"Add Comment\"}\\\" class=\\\"#{\"bg-blue-600 text-white px-4 py-2 rounded\"}\\\">\"\n      io << \"\\n\"\n      io << \"</form>\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/articles/show_json.cr","content":"module Views\n  module Articles\n    def self.show_json(article : Article) : String\n      io = String::Builder.new\n      io << Views::Articles.article_json(article)\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/comments/_comment.cr","content":"module Views\n  module Comments\n    def self.comment(comment : Comment, notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      io << \"<div id=\\\"\"\n      io << ::ActionView::ViewHelpers.dom_id(comment)\n      io << \"\\\" class=\\\"p-4 bg-gray-50 rounded\\\">\\n  <p class=\\\"font-semibold\\\">\"\n      io << ::ActionView::ViewHelpers.html_escape(comment.commenter.not_nil!)\n      io << \"</p>\\n  <p class=\\\"text-gray-700\\\">\"\n      io << ::ActionView::ViewHelpers.html_escape(comment.body.not_nil!)\n      io << \"</p>\\n  \"\n      io << \"<form action=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_comment_path(comment.article_id.not_nil!, comment.id.not_nil!))}\\\" method=\\\"post\\\" class=\\\"#{\"button_to\"}\\\">#{::ActionView::ViewHelpers.method_override_input(:delete)}<button type=\\\"submit\\\" class=\\\"#{\"text-red-600 text-sm mt-2\"}\\\" data-turbo-confirm=\\\"#{\"Are you sure?\"}\\\">#{\"Delete\"}</button>#{::ActionView::ViewHelpers.csrf_token_hidden_input.not_nil!}</form>\"\n      io << \"\\n</div>\\n\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/layouts/application.cr","content":"module Views\n  module Layouts\n    def self.application(body : String, notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      io << \"<!DOCTYPE html>\\n<html>\\n  <head>\\n    <title>\"\n      io << ::ActionView::ViewHelpers.html_escape(::ActionView::ViewHelpers.content_for_get(:title) || \"Real Blog\")\n      io << \"</title>\\n    <meta name=\\\"viewport\\\" content=\\\"width=device-width,initial-scale=1\\\">\\n    <meta name=\\\"apple-mobile-web-app-capable\\\" content=\\\"yes\\\">\\n    <meta name=\\\"application-name\\\" content=\\\"Real Blog\\\">\\n    <meta name=\\\"mobile-web-app-capable\\\" content=\\\"yes\\\">\\n    \"\n      io << ::ActionView::ViewHelpers.csrf_meta_tags.not_nil!\n      io << \"\\n    \"\n      io << ::ActionView::ViewHelpers.csp_meta_tag.not_nil!\n      io << \"\\n\\n    \"\n      io << ::ActionView::ViewHelpers.get_slot(:head)\n      io << \"\\n\\n\\n    <link rel=\\\"icon\\\" href=\\\"/icon.png\\\" type=\\\"image/png\\\">\\n    <link rel=\\\"icon\\\" href=\\\"/icon.svg\\\" type=\\\"image/svg+xml\\\">\\n    <link rel=\\\"apple-touch-icon\\\" href=\\\"/icon.png\\\">\\n\\n    \"\n      io << ::ActionView::ViewHelpers.stylesheet_link_tag(\"application\", { :\"data-turbo-track\" => \"reload\" }) + \"\\n\" + ::ActionView::ViewHelpers.stylesheet_link_tag(\"tailwind\", { :\"data-turbo-track\" => \"reload\" })\n      io << \"\\n    \"\n      io << ::ActionView::ViewHelpers.javascript_importmap_tags(Importmap.pins, Importmap.entry.not_nil!)\n      io << \"\\n  </head>\\n\\n  <body>\\n    <main class=\\\"container mx-auto mt-28 px-5 flex flex-col\\\">\\n      \"\n      io << body\n      io << \"\\n    </main>\\n  </body>\\n</html>\\n\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"src/views/layouts/mailer.cr","content":"module Views\n  module Layouts\n    def self.mailer(body : String, notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      io << \"<!DOCTYPE html>\\n<html>\\n  <head>\\n    <meta http-equiv=\\\"Content-Type\\\" content=\\\"text/html; charset=utf-8\\\">\\n    <style>\\n      /* Email styles need to be inline */\\n    </style>\\n  </head>\\n\\n  <body>\\n    \"\n      io << body\n      io << \"\\n  </body>\\n</html>\\n\"\n      io.to_s\n    end\n  end\nend\n"},{"path":"static/assets/application.js","content":"// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails\nimport \"@hotwired/turbo-rails\"\nimport \"controllers\"\n"},{"path":"static/assets/controllers/application.js","content":"import { Application } from \"@hotwired/stimulus\"\n\nconst application = Application.start()\n\n// Configure Stimulus development experience\napplication.debug = false\nwindow.Stimulus   = application\n\nexport { application }\n"},{"path":"static/assets/controllers/hello_controller.js","content":"import { Controller } from \"@hotwired/stimulus\"\n\nexport default class extends Controller {\n  connect() {\n    this.element.textContent = \"Hello World!\"\n  }\n}\n"},{"path":"static/assets/controllers/index.js","content":"// Import and register all your controllers from the importmap via controllers/**/*_controller\nimport { application } from \"controllers/application\"\nimport { eagerLoadControllersFrom } from \"@hotwired/stimulus-loading\"\neagerLoadControllersFrom(\"controllers\", application)\n"},{"path":"static/assets/stimulus-loading.js","content":"// FIXME: es-module-shim won't shim the dynamic import without this explicit import\nimport \"@hotwired/stimulus\"\n\nconst controllerAttribute = \"data-controller\"\n\n// Eager load all controllers registered beneath the `under` path in the import map to the passed application instance.\nexport function eagerLoadControllersFrom(under, application) {\n  const paths = Object.keys(parseImportmapJson()).filter(path => path.match(new RegExp(`^${under}/.*_controller$`)))\n  paths.forEach(path => registerControllerFromPath(path, under, application))\n}\n\nfunction parseImportmapJson() {\n  return JSON.parse(document.querySelector(\"script[type=importmap]\").text).imports\n}\n\nfunction registerControllerFromPath(path, under, application) {\n  const name = path\n    .replace(new RegExp(`^${under}/`), \"\")\n    .replace(\"_controller\", \"\")\n    .replace(/\\//g, \"--\")\n    .replace(/_/g, \"-\")\n\n  if (canRegisterController(name, application)) {\n    import(path)\n      .then(module => registerController(name, module, application))\n      .catch(error => console.error(`Failed to register controller: ${name} (${path})`, error))\n  }\n}\n\n\n// Lazy load controllers registered beneath the `under` path in the import map to the passed application instance.\nexport function lazyLoadControllersFrom(under, application, element = document) {\n  lazyLoadExistingControllers(under, application, element)\n  lazyLoadNewControllers(under, application, element)\n}\n\nfunction lazyLoadExistingControllers(under, application, element) {\n  queryControllerNamesWithin(element).forEach(controllerName => loadController(controllerName, under, application))\n}\n\nfunction lazyLoadNewControllers(under, application, element) {\n  new MutationObserver((mutationsList) => {\n    for (const { attributeName, target, type } of mutationsList) {\n      switch (type) {\n        case \"attributes\": {\n          if (attributeName == controllerAttribute && target.getAttribute(controllerAttribute)) {\n            extractControllerNamesFrom(target).forEach(controllerName => loadController(controllerName, under, application))\n          }\n        }\n\n        case \"childList\": {\n          lazyLoadExistingControllers(under, application, target)\n        }\n      }\n    }\n  }).observe(element, { attributeFilter: [controllerAttribute], subtree: true, childList: true })\n}\n\nfunction queryControllerNamesWithin(element) {\n  return Array.from(element.querySelectorAll(`[${controllerAttribute}]`)).map(extractControllerNamesFrom).flat()\n}\n\nfunction extractControllerNamesFrom(element) {\n  return element.getAttribute(controllerAttribute).split(/\\s+/).filter(content => content.length)\n}\n\nfunction loadController(name, under, application) {\n  if (canRegisterController(name, application)) {\n    import(controllerFilename(name, under))\n      .then(module => registerController(name, module, application))\n      .catch(error => console.error(`Failed to autoload controller: ${name}`, error))\n  }\n}\n\nfunction controllerFilename(name, under) {\n  return `${under}/${name.replace(/--/g, \"/\").replace(/-/g, \"_\")}_controller`\n}\n\nfunction registerController(name, module, application) {\n  if (canRegisterController(name, application)) {\n    application.register(name, module.default)\n  }\n}\n\nfunction canRegisterController(name, application){\n  return !application.router.modulesByIdentifier.has(name)\n}\n"},{"path":"static/assets/stimulus.min.js","content":"//= link ./stimulus-autoloader.js\n//= link ./stimulus-importmap-autoloader.js\n//= link ./stimulus-loading.js\nclass e{constructor(e,t,s){this.eventTarget=e,this.eventName=t,this.eventOptions=s,this.unorderedBindings=new Set}connect(){this.eventTarget.addEventListener(this.eventName,this,this.eventOptions)}disconnect(){this.eventTarget.removeEventListener(this.eventName,this,this.eventOptions)}bindingConnected(e){this.unorderedBindings.add(e)}bindingDisconnected(e){this.unorderedBindings.delete(e)}handleEvent(e){const t=function(e){if(\"immediatePropagationStopped\"in e)return e;{const{stopImmediatePropagation:t}=e;return Object.assign(e,{immediatePropagationStopped:!1,stopImmediatePropagation(){this.immediatePropagationStopped=!0,t.call(this)}})}}(e);for(const e of this.bindings){if(t.immediatePropagationStopped)break;e.handleEvent(t)}}hasBindings(){return this.unorderedBindings.size>0}get bindings(){return Array.from(this.unorderedBindings).sort(((e,t)=>{const s=e.index,r=t.index;return s<r?-1:s>r?1:0}))}}class t{constructor(e){this.application=e,this.eventListenerMaps=new Map,this.started=!1}start(){this.started||(this.started=!0,this.eventListeners.forEach((e=>e.connect())))}stop(){this.started&&(this.started=!1,this.eventListeners.forEach((e=>e.disconnect())))}get eventListeners(){return Array.from(this.eventListenerMaps.values()).reduce(((e,t)=>e.concat(Array.from(t.values()))),[])}bindingConnected(e){this.fetchEventListenerForBinding(e).bindingConnected(e)}bindingDisconnected(e,t=!1){this.fetchEventListenerForBinding(e).bindingDisconnected(e),t&&this.clearEventListenersForBinding(e)}handleError(e,t,s={}){this.application.handleError(e,`Error ${t}`,s)}clearEventListenersForBinding(e){const t=this.fetchEventListenerForBinding(e);t.hasBindings()||(t.disconnect(),this.removeMappedEventListenerFor(e))}removeMappedEventListenerFor(e){const{eventTarget:t,eventName:s,eventOptions:r}=e,n=this.fetchEventListenerMapForEventTarget(t),i=this.cacheKey(s,r);n.delete(i),0==n.size&&this.eventListenerMaps.delete(t)}fetchEventListenerForBinding(e){const{eventTarget:t,eventName:s,eventOptions:r}=e;return this.fetchEventListener(t,s,r)}fetchEventListener(e,t,s){const r=this.fetchEventListenerMapForEventTarget(e),n=this.cacheKey(t,s);let i=r.get(n);return i||(i=this.createEventListener(e,t,s),r.set(n,i)),i}createEventListener(t,s,r){const n=new e(t,s,r);return this.started&&n.connect(),n}fetchEventListenerMapForEventTarget(e){let t=this.eventListenerMaps.get(e);return t||(t=new Map,this.eventListenerMaps.set(e,t)),t}cacheKey(e,t){const s=[e];return Object.keys(t).sort().forEach((e=>{s.push(`${t[e]?\"\":\"!\"}${e}`)})),s.join(\":\")}}const s={stop:({event:e,value:t})=>(t&&e.stopPropagation(),!0),prevent:({event:e,value:t})=>(t&&e.preventDefault(),!0),self:({event:e,value:t,element:s})=>!t||s===e.target},r=/^(?:(?:([^.]+?)\\+)?(.+?)(?:\\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/;function n(e){return\"window\"==e?window:\"document\"==e?document:void 0}function i(e){return e.replace(/(?:[_-])([a-z0-9])/g,((e,t)=>t.toUpperCase()))}function o(e){return i(e.replace(/--/g,\"-\").replace(/__/g,\"_\"))}function a(e){return e.charAt(0).toUpperCase()+e.slice(1)}function c(e){return e.replace(/([A-Z])/g,((e,t)=>`-${t.toLowerCase()}`))}function l(e){return null!=e}function h(e,t){return Object.prototype.hasOwnProperty.call(e,t)}const u=[\"meta\",\"ctrl\",\"alt\",\"shift\"];const d={a:()=>\"click\",button:()=>\"click\",form:()=>\"submit\",details:()=>\"toggle\",input:e=>\"submit\"==e.getAttribute(\"type\")?\"click\":\"input\",select:()=>\"change\",textarea:()=>\"input\"};function m(e){throw new Error(e)}function g(e){try{return JSON.parse(e)}catch(t){return e}}class p{constructor(e,t){this.context=e,this.action=t}get index(){return this.action.index}get eventTarget(){return this.action.eventTarget}get eventOptions(){return this.action.eventOptions}get identifier(){return this.context.identifier}handleEvent(e){const t=this.prepareActionEvent(e);this.willBeInvokedByEvent(e)&&this.applyEventModifiers(t)&&this.invokeWithEvent(t)}get eventName(){return this.action.eventName}get method(){const e=this.controller[this.methodName];if(\"function\"==typeof e)return e;throw new Error(`Action \"${this.action}\" references undefined method \"${this.methodName}\"`)}applyEventModifiers(e){const{element:t}=this.action,{actionDescriptorFilters:s}=this.context.application,{controller:r}=this.context;let n=!0;for(const[i,o]of Object.entries(this.eventOptions))if(i in s){const a=s[i];n=n&&a({name:i,value:o,event:e,element:t,controller:r})}return n}prepareActionEvent(e){return Object.assign(e,{params:this.action.params})}invokeWithEvent(e){const{target:t,currentTarget:s}=e;try{this.method.call(this.controller,e),this.context.logDebugActivity(this.methodName,{event:e,target:t,currentTarget:s,action:this.methodName})}catch(t){const{identifier:s,controller:r,element:n,index:i}=this,o={identifier:s,controller:r,element:n,index:i,event:e};this.context.handleError(t,`invoking action \"${this.action}\"`,o)}}willBeInvokedByEvent(e){const t=e.target;return!(e instanceof KeyboardEvent&&this.action.shouldIgnoreKeyboardEvent(e))&&(!(e instanceof MouseEvent&&this.action.shouldIgnoreMouseEvent(e))&&(this.element===t||(t instanceof Element&&this.element.contains(t)?this.scope.containsElement(t):this.scope.containsElement(this.action.element))))}get controller(){return this.context.controller}get methodName(){return this.action.methodName}get element(){return this.scope.element}get scope(){return this.context.scope}}class f{constructor(e,t){this.mutationObserverInit={attributes:!0,childList:!0,subtree:!0},this.element=e,this.started=!1,this.delegate=t,this.elements=new Set,this.mutationObserver=new MutationObserver((e=>this.processMutations(e)))}start(){this.started||(this.started=!0,this.mutationObserver.observe(this.element,this.mutationObserverInit),this.refresh())}pause(e){this.started&&(this.mutationObserver.disconnect(),this.started=!1),e(),this.started||(this.mutationObserver.observe(this.element,this.mutationObserverInit),this.started=!0)}stop(){this.started&&(this.mutationObserver.takeRecords(),this.mutationObserver.disconnect(),this.started=!1)}refresh(){if(this.started){const e=new Set(this.matchElementsInTree());for(const t of Array.from(this.elements))e.has(t)||this.removeElement(t);for(const t of Array.from(e))this.addElement(t)}}processMutations(e){if(this.started)for(const t of e)this.processMutation(t)}processMutation(e){\"attributes\"==e.type?this.processAttributeChange(e.target,e.attributeName):\"childList\"==e.type&&(this.processRemovedNodes(e.removedNodes),this.processAddedNodes(e.addedNodes))}processAttributeChange(e,t){this.elements.has(e)?this.delegate.elementAttributeChanged&&this.matchElement(e)?this.delegate.elementAttributeChanged(e,t):this.removeElement(e):this.matchElement(e)&&this.addElement(e)}processRemovedNodes(e){for(const t of Array.from(e)){const e=this.elementFromNode(t);e&&this.processTree(e,this.removeElement)}}processAddedNodes(e){for(const t of Array.from(e)){const e=this.elementFromNode(t);e&&this.elementIsActive(e)&&this.processTree(e,this.addElement)}}matchElement(e){return this.delegate.matchElement(e)}matchElementsInTree(e=this.element){return this.delegate.matchElementsInTree(e)}processTree(e,t){for(const s of this.matchElementsInTree(e))t.call(this,s)}elementFromNode(e){if(e.nodeType==Node.ELEMENT_NODE)return e}elementIsActive(e){return e.isConnected==this.element.isConnected&&this.element.contains(e)}addElement(e){this.elements.has(e)||this.elementIsActive(e)&&(this.elements.add(e),this.delegate.elementMatched&&this.delegate.elementMatched(e))}removeElement(e){this.elements.has(e)&&(this.elements.delete(e),this.delegate.elementUnmatched&&this.delegate.elementUnmatched(e))}}class b{constructor(e,t,s){this.attributeName=t,this.delegate=s,this.elementObserver=new f(e,this)}get element(){return this.elementObserver.element}get selector(){return`[${this.attributeName}]`}start(){this.elementObserver.start()}pause(e){this.elementObserver.pause(e)}stop(){this.elementObserver.stop()}refresh(){this.elementObserver.refresh()}get started(){return this.elementObserver.started}matchElement(e){return e.hasAttribute(this.attributeName)}matchElementsInTree(e){const t=this.matchElement(e)?[e]:[],s=Array.from(e.querySelectorAll(this.selector));return t.concat(s)}elementMatched(e){this.delegate.elementMatchedAttribute&&this.delegate.elementMatchedAttribute(e,this.attributeName)}elementUnmatched(e){this.delegate.elementUnmatchedAttribute&&this.delegate.elementUnmatchedAttribute(e,this.attributeName)}elementAttributeChanged(e,t){this.delegate.elementAttributeValueChanged&&this.attributeName==t&&this.delegate.elementAttributeValueChanged(e,t)}}function v(e,t,s){O(e,t).add(s)}function y(e,t,s){O(e,t).delete(s),A(e,t)}function O(e,t){let s=e.get(t);return s||(s=new Set,e.set(t,s)),s}function A(e,t){const s=e.get(t);null!=s&&0==s.size&&e.delete(t)}class E{constructor(){this.valuesByKey=new Map}get keys(){return Array.from(this.valuesByKey.keys())}get values(){return Array.from(this.valuesByKey.values()).reduce(((e,t)=>e.concat(Array.from(t))),[])}get size(){return Array.from(this.valuesByKey.values()).reduce(((e,t)=>e+t.size),0)}add(e,t){v(this.valuesByKey,e,t)}delete(e,t){y(this.valuesByKey,e,t)}has(e,t){const s=this.valuesByKey.get(e);return null!=s&&s.has(t)}hasKey(e){return this.valuesByKey.has(e)}hasValue(e){return Array.from(this.valuesByKey.values()).some((t=>t.has(e)))}getValuesForKey(e){const t=this.valuesByKey.get(e);return t?Array.from(t):[]}getKeysForValue(e){return Array.from(this.valuesByKey).filter((([t,s])=>s.has(e))).map((([e,t])=>e))}}class w extends E{constructor(){super(),this.keysByValue=new Map}get values(){return Array.from(this.keysByValue.keys())}add(e,t){super.add(e,t),v(this.keysByValue,t,e)}delete(e,t){super.delete(e,t),y(this.keysByValue,t,e)}hasValue(e){return this.keysByValue.has(e)}getKeysForValue(e){const t=this.keysByValue.get(e);return t?Array.from(t):[]}}class M{constructor(e,t,s,r){this._selector=t,this.details=r,this.elementObserver=new f(e,this),this.delegate=s,this.matchesByElement=new E}get started(){return this.elementObserver.started}get selector(){return this._selector}set selector(e){this._selector=e,this.refresh()}start(){this.elementObserver.start()}pause(e){this.elementObserver.pause(e)}stop(){this.elementObserver.stop()}refresh(){this.elementObserver.refresh()}get element(){return this.elementObserver.element}matchElement(e){const{selector:t}=this;if(t){const s=e.matches(t);return this.delegate.selectorMatchElement?s&&this.delegate.selectorMatchElement(e,this.details):s}return!1}matchElementsInTree(e){const{selector:t}=this;if(t){const s=this.matchElement(e)?[e]:[],r=Array.from(e.querySelectorAll(t)).filter((e=>this.matchElement(e)));return s.concat(r)}return[]}elementMatched(e){const{selector:t}=this;t&&this.selectorMatched(e,t)}elementUnmatched(e){const t=this.matchesByElement.getKeysForValue(e);for(const s of t)this.selectorUnmatched(e,s)}elementAttributeChanged(e,t){const{selector:s}=this;if(s){const t=this.matchElement(e),r=this.matchesByElement.has(s,e);t&&!r?this.selectorMatched(e,s):!t&&r&&this.selectorUnmatched(e,s)}}selectorMatched(e,t){this.delegate.selectorMatched(e,t,this.details),this.matchesByElement.add(t,e)}selectorUnmatched(e,t){this.delegate.selectorUnmatched(e,t,this.details),this.matchesByElement.delete(t,e)}}class k{constructor(e,t){this.element=e,this.delegate=t,this.started=!1,this.stringMap=new Map,this.mutationObserver=new MutationObserver((e=>this.processMutations(e)))}start(){this.started||(this.started=!0,this.mutationObserver.observe(this.element,{attributes:!0,attributeOldValue:!0}),this.refresh())}stop(){this.started&&(this.mutationObserver.takeRecords(),this.mutationObserver.disconnect(),this.started=!1)}refresh(){if(this.started)for(const e of this.knownAttributeNames)this.refreshAttribute(e,null)}processMutations(e){if(this.started)for(const t of e)this.processMutation(t)}processMutation(e){const t=e.attributeName;t&&this.refreshAttribute(t,e.oldValue)}refreshAttribute(e,t){const s=this.delegate.getStringMapKeyForAttribute(e);if(null!=s){this.stringMap.has(e)||this.stringMapKeyAdded(s,e);const r=this.element.getAttribute(e);if(this.stringMap.get(e)!=r&&this.stringMapValueChanged(r,s,t),null==r){const t=this.stringMap.get(e);this.stringMap.delete(e),t&&this.stringMapKeyRemoved(s,e,t)}else this.stringMap.set(e,r)}}stringMapKeyAdded(e,t){this.delegate.stringMapKeyAdded&&this.delegate.stringMapKeyAdded(e,t)}stringMapValueChanged(e,t,s){this.delegate.stringMapValueChanged&&this.delegate.stringMapValueChanged(e,t,s)}stringMapKeyRemoved(e,t,s){this.delegate.stringMapKeyRemoved&&this.delegate.stringMapKeyRemoved(e,t,s)}get knownAttributeNames(){return Array.from(new Set(this.currentAttributeNames.concat(this.recordedAttributeNames)))}get currentAttributeNames(){return Array.from(this.element.attributes).map((e=>e.name))}get recordedAttributeNames(){return Array.from(this.stringMap.keys())}}class N{constructor(e,t,s){this.attributeObserver=new b(e,t,this),this.delegate=s,this.tokensByElement=new E}get started(){return this.attributeObserver.started}start(){this.attributeObserver.start()}pause(e){this.attributeObserver.pause(e)}stop(){this.attributeObserver.stop()}refresh(){this.attributeObserver.refresh()}get element(){return this.attributeObserver.element}get attributeName(){return this.attributeObserver.attributeName}elementMatchedAttribute(e){this.tokensMatched(this.readTokensForElement(e))}elementAttributeValueChanged(e){const[t,s]=this.refreshTokensForElement(e);this.tokensUnmatched(t),this.tokensMatched(s)}elementUnmatchedAttribute(e){this.tokensUnmatched(this.tokensByElement.getValuesForKey(e))}tokensMatched(e){e.forEach((e=>this.tokenMatched(e)))}tokensUnmatched(e){e.forEach((e=>this.tokenUnmatched(e)))}tokenMatched(e){this.delegate.tokenMatched(e),this.tokensByElement.add(e.element,e)}tokenUnmatched(e){this.delegate.tokenUnmatched(e),this.tokensByElement.delete(e.element,e)}refreshTokensForElement(e){const t=this.tokensByElement.getValuesForKey(e),s=this.readTokensForElement(e),r=function(e,t){const s=Math.max(e.length,t.length);return Array.from({length:s},((s,r)=>[e[r],t[r]]))}(t,s).findIndex((([e,t])=>{return r=t,!((s=e)&&r&&s.index==r.index&&s.content==r.content);var s,r}));return-1==r?[[],[]]:[t.slice(r),s.slice(r)]}readTokensForElement(e){const t=this.attributeName;return function(e,t,s){return e.trim().split(/\\s+/).filter((e=>e.length)).map(((e,r)=>({element:t,attributeName:s,content:e,index:r})))}(e.getAttribute(t)||\"\",e,t)}}class F{constructor(e,t,s){this.tokenListObserver=new N(e,t,this),this.delegate=s,this.parseResultsByToken=new WeakMap,this.valuesByTokenByElement=new WeakMap}get started(){return this.tokenListObserver.started}start(){this.tokenListObserver.start()}stop(){this.tokenListObserver.stop()}refresh(){this.tokenListObserver.refresh()}get element(){return this.tokenListObserver.element}get attributeName(){return this.tokenListObserver.attributeName}tokenMatched(e){const{element:t}=e,{value:s}=this.fetchParseResultForToken(e);s&&(this.fetchValuesByTokenForElement(t).set(e,s),this.delegate.elementMatchedValue(t,s))}tokenUnmatched(e){const{element:t}=e,{value:s}=this.fetchParseResultForToken(e);s&&(this.fetchValuesByTokenForElement(t).delete(e),this.delegate.elementUnmatchedValue(t,s))}fetchParseResultForToken(e){let t=this.parseResultsByToken.get(e);return t||(t=this.parseToken(e),this.parseResultsByToken.set(e,t)),t}fetchValuesByTokenForElement(e){let t=this.valuesByTokenByElement.get(e);return t||(t=new Map,this.valuesByTokenByElement.set(e,t)),t}parseToken(e){try{return{value:this.delegate.parseValueForToken(e)}}catch(e){return{error:e}}}}class B{constructor(e,t){this.context=e,this.delegate=t,this.bindingsByAction=new Map}start(){this.valueListObserver||(this.valueListObserver=new F(this.element,this.actionAttribute,this),this.valueListObserver.start())}stop(){this.valueListObserver&&(this.valueListObserver.stop(),delete this.valueListObserver,this.disconnectAllActions())}get element(){return this.context.element}get identifier(){return this.context.identifier}get actionAttribute(){return this.schema.actionAttribute}get schema(){return this.context.schema}get bindings(){return Array.from(this.bindingsByAction.values())}connectAction(e){const t=new p(this.context,e);this.bindingsByAction.set(e,t),this.delegate.bindingConnected(t)}disconnectAction(e){const t=this.bindingsByAction.get(e);t&&(this.bindingsByAction.delete(e),this.delegate.bindingDisconnected(t))}disconnectAllActions(){this.bindings.forEach((e=>this.delegate.bindingDisconnected(e,!0))),this.bindingsByAction.clear()}parseValueForToken(e){const t=class{constructor(e,t,s,r){this.element=e,this.index=t,this.eventTarget=s.eventTarget||e,this.eventName=s.eventName||function(e){const t=e.tagName.toLowerCase();if(t in d)return d[t](e)}(e)||m(\"missing event name\"),this.eventOptions=s.eventOptions||{},this.identifier=s.identifier||m(\"missing identifier\"),this.methodName=s.methodName||m(\"missing method name\"),this.keyFilter=s.keyFilter||\"\",this.schema=r}static forToken(e,t){return new this(e.element,e.index,function(e){const t=e.trim().match(r)||[];let s=t[2],i=t[3];return i&&![\"keydown\",\"keyup\",\"keypress\"].includes(s)&&(s+=`.${i}`,i=\"\"),{eventTarget:n(t[4]),eventName:s,eventOptions:t[7]?(o=t[7],o.split(\":\").reduce(((e,t)=>Object.assign(e,{[t.replace(/^!/,\"\")]:!/^!/.test(t)})),{})):{},identifier:t[5],methodName:t[6],keyFilter:t[1]||i};var o}(e.content),t)}toString(){const e=this.keyFilter?`.${this.keyFilter}`:\"\",t=this.eventTargetName?`@${this.eventTargetName}`:\"\";return`${this.eventName}${e}${t}->${this.identifier}#${this.methodName}`}shouldIgnoreKeyboardEvent(e){if(!this.keyFilter)return!1;const t=this.keyFilter.split(\"+\");if(this.keyFilterDissatisfied(e,t))return!0;const s=t.filter((e=>!u.includes(e)))[0];return!!s&&(h(this.keyMappings,s)||m(`contains unknown key filter: ${this.keyFilter}`),this.keyMappings[s].toLowerCase()!==e.key.toLowerCase())}shouldIgnoreMouseEvent(e){if(!this.keyFilter)return!1;const t=[this.keyFilter];return!!this.keyFilterDissatisfied(e,t)}get params(){const e={},t=new RegExp(`^data-${this.identifier}-(.+)-param$`,\"i\");for(const{name:s,value:r}of Array.from(this.element.attributes)){const n=s.match(t),o=n&&n[1];o&&(e[i(o)]=g(r))}return e}get eventTargetName(){return(e=this.eventTarget)==window?\"window\":e==document?\"document\":void 0;var e}get keyMappings(){return this.schema.keyMappings}keyFilterDissatisfied(e,t){const[s,r,n,i]=u.map((e=>t.includes(e)));return e.metaKey!==s||e.ctrlKey!==r||e.altKey!==n||e.shiftKey!==i}}.forToken(e,this.schema);if(t.identifier==this.identifier)return t}elementMatchedValue(e,t){this.connectAction(t)}elementUnmatchedValue(e,t){this.disconnectAction(t)}}class C{constructor(e,t){this.context=e,this.receiver=t,this.stringMapObserver=new k(this.element,this),this.valueDescriptorMap=this.controller.valueDescriptorMap}start(){this.stringMapObserver.start(),this.invokeChangedCallbacksForDefaultValues()}stop(){this.stringMapObserver.stop()}get element(){return this.context.element}get controller(){return this.context.controller}getStringMapKeyForAttribute(e){if(e in this.valueDescriptorMap)return this.valueDescriptorMap[e].name}stringMapKeyAdded(e,t){const s=this.valueDescriptorMap[t];this.hasValue(e)||this.invokeChangedCallback(e,s.writer(this.receiver[e]),s.writer(s.defaultValue))}stringMapValueChanged(e,t,s){const r=this.valueDescriptorNameMap[t];null!==e&&(null===s&&(s=r.writer(r.defaultValue)),this.invokeChangedCallback(t,e,s))}stringMapKeyRemoved(e,t,s){const r=this.valueDescriptorNameMap[e];this.hasValue(e)?this.invokeChangedCallback(e,r.writer(this.receiver[e]),s):this.invokeChangedCallback(e,r.writer(r.defaultValue),s)}invokeChangedCallbacksForDefaultValues(){for(const{key:e,name:t,defaultValue:s,writer:r}of this.valueDescriptors)null==s||this.controller.data.has(e)||this.invokeChangedCallback(t,r(s),void 0)}invokeChangedCallback(e,t,s){const r=`${e}Changed`,n=this.receiver[r];if(\"function\"==typeof n){const r=this.valueDescriptorNameMap[e];try{const e=r.reader(t);let i=s;s&&(i=r.reader(s)),n.call(this.receiver,e,i)}catch(e){throw e instanceof TypeError&&(e.message=`Stimulus Value \"${this.context.identifier}.${r.name}\" - ${e.message}`),e}}}get valueDescriptors(){const{valueDescriptorMap:e}=this;return Object.keys(e).map((t=>e[t]))}get valueDescriptorNameMap(){const e={};return Object.keys(this.valueDescriptorMap).forEach((t=>{const s=this.valueDescriptorMap[t];e[s.name]=s})),e}hasValue(e){const t=`has${a(this.valueDescriptorNameMap[e].name)}`;return this.receiver[t]}}class ${constructor(e,t){this.context=e,this.delegate=t,this.targetsByName=new E}start(){this.tokenListObserver||(this.tokenListObserver=new N(this.element,this.attributeName,this),this.tokenListObserver.start())}stop(){this.tokenListObserver&&(this.disconnectAllTargets(),this.tokenListObserver.stop(),delete this.tokenListObserver)}tokenMatched({element:e,content:t}){this.scope.containsElement(e)&&this.connectTarget(e,t)}tokenUnmatched({element:e,content:t}){this.disconnectTarget(e,t)}connectTarget(e,t){var s;this.targetsByName.has(t,e)||(this.targetsByName.add(t,e),null===(s=this.tokenListObserver)||void 0===s||s.pause((()=>this.delegate.targetConnected(e,t))))}disconnectTarget(e,t){var s;this.targetsByName.has(t,e)&&(this.targetsByName.delete(t,e),null===(s=this.tokenListObserver)||void 0===s||s.pause((()=>this.delegate.targetDisconnected(e,t))))}disconnectAllTargets(){for(const e of this.targetsByName.keys)for(const t of this.targetsByName.getValuesForKey(e))this.disconnectTarget(t,e)}get attributeName(){return`data-${this.context.identifier}-target`}get element(){return this.context.element}get scope(){return this.context.scope}}function T(e,t){const s=x(e);return Array.from(s.reduce(((e,s)=>(function(e,t){const s=e[t];return Array.isArray(s)?s:[]}(s,t).forEach((t=>e.add(t))),e)),new Set))}function S(e,t){return x(e).reduce(((e,s)=>(e.push(...function(e,t){const s=e[t];return s?Object.keys(s).map((e=>[e,s[e]])):[]}(s,t)),e)),[])}function x(e){const t=[];for(;e;)t.push(e),e=Object.getPrototypeOf(e);return t.reverse()}class D{constructor(e,t){this.started=!1,this.context=e,this.delegate=t,this.outletsByName=new E,this.outletElementsByName=new E,this.selectorObserverMap=new Map,this.attributeObserverMap=new Map}start(){this.started||(this.outletDefinitions.forEach((e=>{this.setupSelectorObserverForOutlet(e),this.setupAttributeObserverForOutlet(e)})),this.started=!0,this.dependentContexts.forEach((e=>e.refresh())))}refresh(){this.selectorObserverMap.forEach((e=>e.refresh())),this.attributeObserverMap.forEach((e=>e.refresh()))}stop(){this.started&&(this.started=!1,this.disconnectAllOutlets(),this.stopSelectorObservers(),this.stopAttributeObservers())}stopSelectorObservers(){this.selectorObserverMap.size>0&&(this.selectorObserverMap.forEach((e=>e.stop())),this.selectorObserverMap.clear())}stopAttributeObservers(){this.attributeObserverMap.size>0&&(this.attributeObserverMap.forEach((e=>e.stop())),this.attributeObserverMap.clear())}selectorMatched(e,t,{outletName:s}){const r=this.getOutlet(e,s);r&&this.connectOutlet(r,e,s)}selectorUnmatched(e,t,{outletName:s}){const r=this.getOutletFromMap(e,s);r&&this.disconnectOutlet(r,e,s)}selectorMatchElement(e,{outletName:t}){const s=this.selector(t),r=this.hasOutlet(e,t),n=e.matches(`[${this.schema.controllerAttribute}~=${t}]`);return!!s&&(r&&n&&e.matches(s))}elementMatchedAttribute(e,t){const s=this.getOutletNameFromOutletAttributeName(t);s&&this.updateSelectorObserverForOutlet(s)}elementAttributeValueChanged(e,t){const s=this.getOutletNameFromOutletAttributeName(t);s&&this.updateSelectorObserverForOutlet(s)}elementUnmatchedAttribute(e,t){const s=this.getOutletNameFromOutletAttributeName(t);s&&this.updateSelectorObserverForOutlet(s)}connectOutlet(e,t,s){var r;this.outletElementsByName.has(s,t)||(this.outletsByName.add(s,e),this.outletElementsByName.add(s,t),null===(r=this.selectorObserverMap.get(s))||void 0===r||r.pause((()=>this.delegate.outletConnected(e,t,s))))}disconnectOutlet(e,t,s){var r;this.outletElementsByName.has(s,t)&&(this.outletsByName.delete(s,e),this.outletElementsByName.delete(s,t),null===(r=this.selectorObserverMap.get(s))||void 0===r||r.pause((()=>this.delegate.outletDisconnected(e,t,s))))}disconnectAllOutlets(){for(const e of this.outletElementsByName.keys)for(const t of this.outletElementsByName.getValuesForKey(e))for(const s of this.outletsByName.getValuesForKey(e))this.disconnectOutlet(s,t,e)}updateSelectorObserverForOutlet(e){const t=this.selectorObserverMap.get(e);t&&(t.selector=this.selector(e))}setupSelectorObserverForOutlet(e){const t=this.selector(e),s=new M(document.body,t,this,{outletName:e});this.selectorObserverMap.set(e,s),s.start()}setupAttributeObserverForOutlet(e){const t=this.attributeNameForOutletName(e),s=new b(this.scope.element,t,this);this.attributeObserverMap.set(e,s),s.start()}selector(e){return this.scope.outlets.getSelectorForOutletName(e)}attributeNameForOutletName(e){return this.scope.schema.outletAttributeForScope(this.identifier,e)}getOutletNameFromOutletAttributeName(e){return this.outletDefinitions.find((t=>this.attributeNameForOutletName(t)===e))}get outletDependencies(){const e=new E;return this.router.modules.forEach((t=>{T(t.definition.controllerConstructor,\"outlets\").forEach((s=>e.add(s,t.identifier)))})),e}get outletDefinitions(){return this.outletDependencies.getKeysForValue(this.identifier)}get dependentControllerIdentifiers(){return this.outletDependencies.getValuesForKey(this.identifier)}get dependentContexts(){const e=this.dependentControllerIdentifiers;return this.router.contexts.filter((t=>e.includes(t.identifier)))}hasOutlet(e,t){return!!this.getOutlet(e,t)||!!this.getOutletFromMap(e,t)}getOutlet(e,t){return this.application.getControllerForElementAndIdentifier(e,t)}getOutletFromMap(e,t){return this.outletsByName.getValuesForKey(t).find((t=>t.element===e))}get scope(){return this.context.scope}get schema(){return this.context.schema}get identifier(){return this.context.identifier}get application(){return this.context.application}get router(){return this.application.router}}class L{constructor(e,t){this.logDebugActivity=(e,t={})=>{const{identifier:s,controller:r,element:n}=this;t=Object.assign({identifier:s,controller:r,element:n},t),this.application.logDebugActivity(this.identifier,e,t)},this.module=e,this.scope=t,this.controller=new e.controllerConstructor(this),this.bindingObserver=new B(this,this.dispatcher),this.valueObserver=new C(this,this.controller),this.targetObserver=new $(this,this),this.outletObserver=new D(this,this);try{this.controller.initialize(),this.logDebugActivity(\"initialize\")}catch(e){this.handleError(e,\"initializing controller\")}}connect(){this.bindingObserver.start(),this.valueObserver.start(),this.targetObserver.start(),this.outletObserver.start();try{this.controller.connect(),this.logDebugActivity(\"connect\")}catch(e){this.handleError(e,\"connecting controller\")}}refresh(){this.outletObserver.refresh()}disconnect(){try{this.controller.disconnect(),this.logDebugActivity(\"disconnect\")}catch(e){this.handleError(e,\"disconnecting controller\")}this.outletObserver.stop(),this.targetObserver.stop(),this.valueObserver.stop(),this.bindingObserver.stop()}get application(){return this.module.application}get identifier(){return this.module.identifier}get schema(){return this.application.schema}get dispatcher(){return this.application.dispatcher}get element(){return this.scope.element}get parentElement(){return this.element.parentElement}handleError(e,t,s={}){const{identifier:r,controller:n,element:i}=this;s=Object.assign({identifier:r,controller:n,element:i},s),this.application.handleError(e,`Error ${t}`,s)}targetConnected(e,t){this.invokeControllerMethod(`${t}TargetConnected`,e)}targetDisconnected(e,t){this.invokeControllerMethod(`${t}TargetDisconnected`,e)}outletConnected(e,t,s){this.invokeControllerMethod(`${o(s)}OutletConnected`,e,t)}outletDisconnected(e,t,s){this.invokeControllerMethod(`${o(s)}OutletDisconnected`,e,t)}invokeControllerMethod(e,...t){const s=this.controller;\"function\"==typeof s[e]&&s[e](...t)}}function V(e){return function(e,t){const s=I(e),r=function(e,t){return K(t).reduce(((s,r)=>{const n=function(e,t,s){const r=Object.getOwnPropertyDescriptor(e,s);if(!r||!(\"value\"in r)){const e=Object.getOwnPropertyDescriptor(t,s).value;return r&&(e.get=r.get||e.get,e.set=r.set||e.set),e}}(e,t,r);return n&&Object.assign(s,{[r]:n}),s}),{})}(e.prototype,t);return Object.defineProperties(s.prototype,r),s}(e,function(e){return T(e,\"blessings\").reduce(((t,s)=>{const r=s(e);for(const e in r){const s=t[e]||{};t[e]=Object.assign(s,r[e])}return t}),{})}(e))}const K=\"function\"==typeof Object.getOwnPropertySymbols?e=>[...Object.getOwnPropertyNames(e),...Object.getOwnPropertySymbols(e)]:Object.getOwnPropertyNames,I=(()=>{function e(e){function t(){return Reflect.construct(e,arguments,new.target)}return t.prototype=Object.create(e.prototype,{constructor:{value:t}}),Reflect.setPrototypeOf(t,e),t}try{return function(){const t=e((function(){this.a.call(this)}));t.prototype.a=function(){},new t}(),e}catch(e){return e=>class extends e{}}})();class j{constructor(e,t){this.application=e,this.definition=function(e){return{identifier:e.identifier,controllerConstructor:V(e.controllerConstructor)}}(t),this.contextsByScope=new WeakMap,this.connectedContexts=new Set}get identifier(){return this.definition.identifier}get controllerConstructor(){return this.definition.controllerConstructor}get contexts(){return Array.from(this.connectedContexts)}connectContextForScope(e){const t=this.fetchContextForScope(e);this.connectedContexts.add(t),t.connect()}disconnectContextForScope(e){const t=this.contextsByScope.get(e);t&&(this.connectedContexts.delete(t),t.disconnect())}fetchContextForScope(e){let t=this.contextsByScope.get(e);return t||(t=new L(this,e),this.contextsByScope.set(e,t)),t}}class U{constructor(e){this.scope=e}has(e){return this.data.has(this.getDataKey(e))}get(e){return this.getAll(e)[0]}getAll(e){const t=this.data.get(this.getDataKey(e))||\"\";return t.match(/[^\\s]+/g)||[]}getAttributeName(e){return this.data.getAttributeNameForKey(this.getDataKey(e))}getDataKey(e){return`${e}-class`}get data(){return this.scope.data}}class P{constructor(e){this.scope=e}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get(e){const t=this.getAttributeNameForKey(e);return this.element.getAttribute(t)}set(e,t){const s=this.getAttributeNameForKey(e);return this.element.setAttribute(s,t),this.get(e)}has(e){const t=this.getAttributeNameForKey(e);return this.element.hasAttribute(t)}delete(e){if(this.has(e)){const t=this.getAttributeNameForKey(e);return this.element.removeAttribute(t),!0}return!1}getAttributeNameForKey(e){return`data-${this.identifier}-${c(e)}`}}class R{constructor(e){this.warnedKeysByObject=new WeakMap,this.logger=e}warn(e,t,s){let r=this.warnedKeysByObject.get(e);r||(r=new Set,this.warnedKeysByObject.set(e,r)),r.has(t)||(r.add(t),this.logger.warn(s,e))}}function z(e,t){return`[${e}~=\"${t}\"]`}class _{constructor(e){this.scope=e}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get schema(){return this.scope.schema}has(e){return null!=this.find(e)}find(...e){return e.reduce(((e,t)=>e||this.findTarget(t)||this.findLegacyTarget(t)),void 0)}findAll(...e){return e.reduce(((e,t)=>[...e,...this.findAllTargets(t),...this.findAllLegacyTargets(t)]),[])}findTarget(e){const t=this.getSelectorForTargetName(e);return this.scope.findElement(t)}findAllTargets(e){const t=this.getSelectorForTargetName(e);return this.scope.findAllElements(t)}getSelectorForTargetName(e){return z(this.schema.targetAttributeForScope(this.identifier),e)}findLegacyTarget(e){const t=this.getLegacySelectorForTargetName(e);return this.deprecate(this.scope.findElement(t),e)}findAllLegacyTargets(e){const t=this.getLegacySelectorForTargetName(e);return this.scope.findAllElements(t).map((t=>this.deprecate(t,e)))}getLegacySelectorForTargetName(e){const t=`${this.identifier}.${e}`;return z(this.schema.targetAttribute,t)}deprecate(e,t){if(e){const{identifier:s}=this,r=this.schema.targetAttribute,n=this.schema.targetAttributeForScope(s);this.guide.warn(e,`target:${t}`,`Please replace ${r}=\"${s}.${t}\" with ${n}=\"${t}\". The ${r} attribute is deprecated and will be removed in a future version of Stimulus.`)}return e}get guide(){return this.scope.guide}}class q{constructor(e,t){this.scope=e,this.controllerElement=t}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get schema(){return this.scope.schema}has(e){return null!=this.find(e)}find(...e){return e.reduce(((e,t)=>e||this.findOutlet(t)),void 0)}findAll(...e){return e.reduce(((e,t)=>[...e,...this.findAllOutlets(t)]),[])}getSelectorForOutletName(e){const t=this.schema.outletAttributeForScope(this.identifier,e);return this.controllerElement.getAttribute(t)}findOutlet(e){const t=this.getSelectorForOutletName(e);if(t)return this.findElement(t,e)}findAllOutlets(e){const t=this.getSelectorForOutletName(e);return t?this.findAllElements(t,e):[]}findElement(e,t){return this.scope.queryElements(e).filter((s=>this.matchesElement(s,e,t)))[0]}findAllElements(e,t){return this.scope.queryElements(e).filter((s=>this.matchesElement(s,e,t)))}matchesElement(e,t,s){const r=e.getAttribute(this.scope.schema.controllerAttribute)||\"\";return e.matches(t)&&r.split(\" \").includes(s)}}class W{constructor(e,t,s,r){this.targets=new _(this),this.classes=new U(this),this.data=new P(this),this.containsElement=e=>e.closest(this.controllerSelector)===this.element,this.schema=e,this.element=t,this.identifier=s,this.guide=new R(r),this.outlets=new q(this.documentScope,t)}findElement(e){return this.element.matches(e)?this.element:this.queryElements(e).find(this.containsElement)}findAllElements(e){return[...this.element.matches(e)?[this.element]:[],...this.queryElements(e).filter(this.containsElement)]}queryElements(e){return Array.from(this.element.querySelectorAll(e))}get controllerSelector(){return z(this.schema.controllerAttribute,this.identifier)}get isDocumentScope(){return this.element===document.documentElement}get documentScope(){return this.isDocumentScope?this:new W(this.schema,document.documentElement,this.identifier,this.guide.logger)}}class J{constructor(e,t,s){this.element=e,this.schema=t,this.delegate=s,this.valueListObserver=new F(this.element,this.controllerAttribute,this),this.scopesByIdentifierByElement=new WeakMap,this.scopeReferenceCounts=new WeakMap}start(){this.valueListObserver.start()}stop(){this.valueListObserver.stop()}get controllerAttribute(){return this.schema.controllerAttribute}parseValueForToken(e){const{element:t,content:s}=e;return this.parseValueForElementAndIdentifier(t,s)}parseValueForElementAndIdentifier(e,t){const s=this.fetchScopesByIdentifierForElement(e);let r=s.get(t);return r||(r=this.delegate.createScopeForElementAndIdentifier(e,t),s.set(t,r)),r}elementMatchedValue(e,t){const s=(this.scopeReferenceCounts.get(t)||0)+1;this.scopeReferenceCounts.set(t,s),1==s&&this.delegate.scopeConnected(t)}elementUnmatchedValue(e,t){const s=this.scopeReferenceCounts.get(t);s&&(this.scopeReferenceCounts.set(t,s-1),1==s&&this.delegate.scopeDisconnected(t))}fetchScopesByIdentifierForElement(e){let t=this.scopesByIdentifierByElement.get(e);return t||(t=new Map,this.scopesByIdentifierByElement.set(e,t)),t}}class H{constructor(e){this.application=e,this.scopeObserver=new J(this.element,this.schema,this),this.scopesByIdentifier=new E,this.modulesByIdentifier=new Map}get element(){return this.application.element}get schema(){return this.application.schema}get logger(){return this.application.logger}get controllerAttribute(){return this.schema.controllerAttribute}get modules(){return Array.from(this.modulesByIdentifier.values())}get contexts(){return this.modules.reduce(((e,t)=>e.concat(t.contexts)),[])}start(){this.scopeObserver.start()}stop(){this.scopeObserver.stop()}loadDefinition(e){this.unloadIdentifier(e.identifier);const t=new j(this.application,e);this.connectModule(t);const s=e.controllerConstructor.afterLoad;s&&s.call(e.controllerConstructor,e.identifier,this.application)}unloadIdentifier(e){const t=this.modulesByIdentifier.get(e);t&&this.disconnectModule(t)}getContextForElementAndIdentifier(e,t){const s=this.modulesByIdentifier.get(t);if(s)return s.contexts.find((t=>t.element==e))}proposeToConnectScopeForElementAndIdentifier(e,t){const s=this.scopeObserver.parseValueForElementAndIdentifier(e,t);s?this.scopeObserver.elementMatchedValue(s.element,s):console.error(`Couldn't find or create scope for identifier: \"${t}\" and element:`,e)}handleError(e,t,s){this.application.handleError(e,t,s)}createScopeForElementAndIdentifier(e,t){return new W(this.schema,e,t,this.logger)}scopeConnected(e){this.scopesByIdentifier.add(e.identifier,e);const t=this.modulesByIdentifier.get(e.identifier);t&&t.connectContextForScope(e)}scopeDisconnected(e){this.scopesByIdentifier.delete(e.identifier,e);const t=this.modulesByIdentifier.get(e.identifier);t&&t.disconnectContextForScope(e)}connectModule(e){this.modulesByIdentifier.set(e.identifier,e);this.scopesByIdentifier.getValuesForKey(e.identifier).forEach((t=>e.connectContextForScope(t)))}disconnectModule(e){this.modulesByIdentifier.delete(e.identifier);this.scopesByIdentifier.getValuesForKey(e.identifier).forEach((t=>e.disconnectContextForScope(t)))}}const Z={controllerAttribute:\"data-controller\",actionAttribute:\"data-action\",targetAttribute:\"data-target\",targetAttributeForScope:e=>`data-${e}-target`,outletAttributeForScope:(e,t)=>`data-${e}-${t}-outlet`,keyMappings:Object.assign(Object.assign({enter:\"Enter\",tab:\"Tab\",esc:\"Escape\",space:\" \",up:\"ArrowUp\",down:\"ArrowDown\",left:\"ArrowLeft\",right:\"ArrowRight\",home:\"Home\",end:\"End\",page_up:\"PageUp\",page_down:\"PageDown\"},G(\"abcdefghijklmnopqrstuvwxyz\".split(\"\").map((e=>[e,e])))),G(\"0123456789\".split(\"\").map((e=>[e,e]))))};function G(e){return e.reduce(((e,[t,s])=>Object.assign(Object.assign({},e),{[t]:s})),{})}class Q{constructor(e=document.documentElement,r=Z){this.logger=console,this.debug=!1,this.logDebugActivity=(e,t,s={})=>{this.debug&&this.logFormattedMessage(e,t,s)},this.element=e,this.schema=r,this.dispatcher=new t(this),this.router=new H(this),this.actionDescriptorFilters=Object.assign({},s)}static start(e,t){const s=new this(e,t);return s.start(),s}async start(){await new Promise((e=>{\"loading\"==document.readyState?document.addEventListener(\"DOMContentLoaded\",(()=>e())):e()})),this.logDebugActivity(\"application\",\"starting\"),this.dispatcher.start(),this.router.start(),this.logDebugActivity(\"application\",\"start\")}stop(){this.logDebugActivity(\"application\",\"stopping\"),this.dispatcher.stop(),this.router.stop(),this.logDebugActivity(\"application\",\"stop\")}register(e,t){this.load({identifier:e,controllerConstructor:t})}registerActionOption(e,t){this.actionDescriptorFilters[e]=t}load(e,...t){(Array.isArray(e)?e:[e,...t]).forEach((e=>{e.controllerConstructor.shouldLoad&&this.router.loadDefinition(e)}))}unload(e,...t){(Array.isArray(e)?e:[e,...t]).forEach((e=>this.router.unloadIdentifier(e)))}get controllers(){return this.router.contexts.map((e=>e.controller))}getControllerForElementAndIdentifier(e,t){const s=this.router.getContextForElementAndIdentifier(e,t);return s?s.controller:null}handleError(e,t,s){var r;this.logger.error(\"%s\\n\\n%o\\n\\n%o\",t,e,s),null===(r=window.onerror)||void 0===r||r.call(window,t,\"\",0,0,e)}logFormattedMessage(e,t,s={}){s=Object.assign({application:this},s),this.logger.groupCollapsed(`${e} #${t}`),this.logger.log(\"details:\",Object.assign({},s)),this.logger.groupEnd()}}function X(e,t,s){return e.application.getControllerForElementAndIdentifier(t,s)}function Y(e,t,s){let r=X(e,t,s);return r||(e.application.router.proposeToConnectScopeForElementAndIdentifier(t,s),r=X(e,t,s),r||void 0)}function ee([e,t],s){return function(e){const{token:t,typeDefinition:s}=e,r=`${c(t)}-value`,n=function(e){const{controller:t,token:s,typeDefinition:r}=e,n=function(e){const{controller:t,token:s,typeObject:r}=e,n=l(r.type),i=l(r.default),o=n&&i,a=n&&!i,c=!n&&i,h=te(r.type),u=se(e.typeObject.default);if(a)return h;if(c)return u;if(h!==u){throw new Error(`The specified default value for the Stimulus Value \"${t?`${t}.${s}`:s}\" must match the defined type \"${h}\". The provided default value of \"${r.default}\" is of type \"${u}\".`)}if(o)return h}({controller:t,token:s,typeObject:r}),i=se(r),o=te(r),a=n||i||o;if(a)return a;throw new Error(`Unknown value type \"${t?`${t}.${r}`:s}\" for \"${s}\" value`)}(e);return{type:n,key:r,name:i(r),get defaultValue(){return function(e){const t=te(e);if(t)return re[t];const s=h(e,\"default\"),r=h(e,\"type\"),n=e;if(s)return n.default;if(r){const{type:e}=n,t=te(e);if(t)return re[t]}return e}(s)},get hasCustomDefaultValue(){return void 0!==se(s)},reader:ne[n],writer:ie[n]||ie.default}}({controller:s,token:e,typeDefinition:t})}function te(e){switch(e){case Array:return\"array\";case Boolean:return\"boolean\";case Number:return\"number\";case Object:return\"object\";case String:return\"string\"}}function se(e){switch(typeof e){case\"boolean\":return\"boolean\";case\"number\":return\"number\";case\"string\":return\"string\"}return Array.isArray(e)?\"array\":\"[object Object]\"===Object.prototype.toString.call(e)?\"object\":void 0}const re={get array(){return[]},boolean:!1,number:0,get object(){return{}},string:\"\"},ne={array(e){const t=JSON.parse(e);if(!Array.isArray(t))throw new TypeError(`expected value of type \"array\" but instead got value \"${e}\" of type \"${se(t)}\"`);return t},boolean:e=>!(\"0\"==e||\"false\"==String(e).toLowerCase()),number:e=>Number(e.replace(/_/g,\"\")),object(e){const t=JSON.parse(e);if(null===t||\"object\"!=typeof t||Array.isArray(t))throw new TypeError(`expected value of type \"object\" but instead got value \"${e}\" of type \"${se(t)}\"`);return t},string:e=>e},ie={default:function(e){return`${e}`},array:oe,object:oe};function oe(e){return JSON.stringify(e)}class ae{constructor(e){this.context=e}static get shouldLoad(){return!0}static afterLoad(e,t){}get application(){return this.context.application}get scope(){return this.context.scope}get element(){return this.scope.element}get identifier(){return this.scope.identifier}get targets(){return this.scope.targets}get outlets(){return this.scope.outlets}get classes(){return this.scope.classes}get data(){return this.scope.data}initialize(){}connect(){}disconnect(){}dispatch(e,{target:t=this.element,detail:s={},prefix:r=this.identifier,bubbles:n=!0,cancelable:i=!0}={}){const o=new CustomEvent(r?`${r}:${e}`:e,{detail:s,bubbles:n,cancelable:i});return t.dispatchEvent(o),o}}ae.blessings=[function(e){return T(e,\"classes\").reduce(((e,t)=>{return Object.assign(e,{[`${s=t}Class`]:{get(){const{classes:e}=this;if(e.has(s))return e.get(s);{const t=e.getAttributeName(s);throw new Error(`Missing attribute \"${t}\"`)}}},[`${s}Classes`]:{get(){return this.classes.getAll(s)}},[`has${a(s)}Class`]:{get(){return this.classes.has(s)}}});var s}),{})},function(e){return T(e,\"targets\").reduce(((e,t)=>{return Object.assign(e,{[`${s=t}Target`]:{get(){const e=this.targets.find(s);if(e)return e;throw new Error(`Missing target element \"${s}\" for \"${this.identifier}\" controller`)}},[`${s}Targets`]:{get(){return this.targets.findAll(s)}},[`has${a(s)}Target`]:{get(){return this.targets.has(s)}}});var s}),{})},function(e){const t=S(e,\"values\"),s={valueDescriptorMap:{get(){return t.reduce(((e,t)=>{const s=ee(t,this.identifier),r=this.data.getAttributeNameForKey(s.key);return Object.assign(e,{[r]:s})}),{})}}};return t.reduce(((e,t)=>Object.assign(e,function(e,t){const s=ee(e,t),{key:r,name:n,reader:i,writer:o}=s;return{[n]:{get(){const e=this.data.get(r);return null!==e?i(e):s.defaultValue},set(e){void 0===e?this.data.delete(r):this.data.set(r,o(e))}},[`has${a(n)}`]:{get(){return this.data.has(r)||s.hasCustomDefaultValue}}}}(t))),s)},function(e){return T(e,\"outlets\").reduce(((e,t)=>Object.assign(e,function(e){const t=o(e);return{[`${t}Outlet`]:{get(){const t=this.outlets.find(e),s=this.outlets.getSelectorForOutletName(e);if(t){const s=Y(this,t,e);if(s)return s;throw new Error(`The provided outlet element is missing an outlet controller \"${e}\" instance for host controller \"${this.identifier}\"`)}throw new Error(`Missing outlet element \"${e}\" for host controller \"${this.identifier}\". Stimulus couldn't find a matching outlet element using selector \"${s}\".`)}},[`${t}Outlets`]:{get(){const t=this.outlets.findAll(e);return t.length>0?t.map((t=>{const s=Y(this,t,e);if(s)return s;console.warn(`The provided outlet element is missing an outlet controller \"${e}\" instance for host controller \"${this.identifier}\"`,t)})).filter((e=>e)):[]}},[`${t}OutletElement`]:{get(){const t=this.outlets.find(e),s=this.outlets.getSelectorForOutletName(e);if(t)return t;throw new Error(`Missing outlet element \"${e}\" for host controller \"${this.identifier}\". Stimulus couldn't find a matching outlet element using selector \"${s}\".`)}},[`${t}OutletElements`]:{get(){return this.outlets.findAll(e)}},[`has${a(t)}Outlet`]:{get(){return this.outlets.has(e)}}}}(t))),{})}],ae.targets=[],ae.outlets=[],ae.values={};export{Q as Application,b as AttributeObserver,L as Context,ae as Controller,f as ElementObserver,w as IndexedMultimap,E as Multimap,M as SelectorObserver,k as StringMapObserver,N as TokenListObserver,F as ValueListObserver,v as add,Z as defaultSchema,y as del,O as fetch,A as prune};\n//# sourceMappingURL=stimulus.min.js.map\n"},{"path":"static/assets/tailwind.css","content":"/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-green-50:oklch(98.2% .018 155.826);--color-green-500:oklch(72.3% .219 149.579);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-gray-50:oklch(98.5% .002 247.839);--color-gray-100:oklch(96.7% .003 264.542);--color-gray-200:oklch(92.8% .006 264.531);--color-gray-400:oklch(70.7% .022 261.325);--color-gray-500:oklch(55.1% .027 264.364);--color-gray-700:oklch(37.3% .034 259.733);--color-white:#fff;--spacing:.25rem;--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--radius-md:.375rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.fixed{position:fixed}.sticky{position:sticky}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.my-4{margin-block:calc(var(--spacing) * 4)}.my-5{margin-block:calc(var(--spacing) * 5)}.my-8{margin-block:calc(var(--spacing) * 8)}.my-10{margin-block:calc(var(--spacing) * 10)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-28{margin-top:calc(var(--spacing) * 28)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-6{margin-left:calc(var(--spacing) * 6)}.block{display:block}.contents{display:contents}.flex{display:flex}.inline{display:inline}.inline-block{display:inline-block}.w-full{width:100%}.min-w-full{min-width:100%}.flex-grow{flex-grow:1}.cursor-pointer{cursor:pointer}.list-disc{list-style-type:disc}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-gray-200>:not(:last-child)){border-color:var(--color-gray-200)}.rounded{border-radius:.25rem}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-gray-400{border-color:var(--color-gray-400)}.border-red-400{border-color:var(--color-red-400)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-100{background-color:var(--color-gray-100)}.bg-green-50{background-color:var(--color-green-50)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-600{background-color:var(--color-red-600)}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\\.5{padding-block:calc(var(--spacing) * 2.5)}.pb-5{padding-bottom:calc(var(--spacing) * 5)}.text-center{text-align:center}.text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.text-blue-600{color:var(--color-blue-600)}.text-gray-500{color:var(--color-gray-500)}.text-gray-700{color:var(--color-gray-700)}.text-green-500{color:var(--color-green-500)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-white{color:var(--color-white)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.hover\\:bg-blue-500:hover{background-color:var(--color-blue-500)}.hover\\:bg-gray-50:hover{background-color:var(--color-gray-50)}.hover\\:bg-red-500:hover{background-color:var(--color-red-500)}.hover\\:underline:hover{text-decoration-line:underline}}.focus\\:outline-blue-600:focus{outline-color:var(--color-blue-600)}.focus\\:outline-red-600:focus{outline-color:var(--color-red-600)}@media (min-width:40rem){.sm\\:mt-0{margin-top:calc(var(--spacing) * 0)}.sm\\:ml-2{margin-left:calc(var(--spacing) * 2)}.sm\\:inline-block{display:inline-block}.sm\\:w-auto{width:auto}.sm\\:flex-row{flex-direction:row}.sm\\:pb-0{padding-bottom:calc(var(--spacing) * 0)}}@media (min-width:48rem){.md\\:w-2\\/3{width:66.6667%}}}@property --tw-space-y-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:\"*\";inherits:false;initial-value:0}@property --tw-border-style{syntax:\"*\";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:\"*\";inherits:false}@property --tw-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:\"*\";inherits:false}@property --tw-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:\"*\";inherits:false}@property --tw-inset-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:\"*\";inherits:false}@property --tw-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:\"*\";inherits:false}@property --tw-inset-ring-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:\"*\";inherits:false}@property --tw-ring-offset-width{syntax:\"<length>\";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:\"*\";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:\"*\";inherits:false;initial-value:0 0 #0000}"},{"path":"static/assets/turbo.min.js","content":"/*!\nTurbo 8.0.23\nCopyright © 2026 37signals LLC\n */\nconst e={eager:\"eager\",lazy:\"lazy\"};class t extends HTMLElement{static delegateConstructor=void 0;loaded=Promise.resolve();static get observedAttributes(){return[\"disabled\",\"loading\",\"src\"]}constructor(){super(),this.delegate=new t.delegateConstructor(this)}connectedCallback(){this.delegate.connect()}disconnectedCallback(){this.delegate.disconnect()}reload(){return this.delegate.sourceURLReloaded()}attributeChangedCallback(e){\"loading\"==e?this.delegate.loadingStyleChanged():\"src\"==e?this.delegate.sourceURLChanged():\"disabled\"==e&&this.delegate.disabledChanged()}get src(){return this.getAttribute(\"src\")}set src(e){e?this.setAttribute(\"src\",e):this.removeAttribute(\"src\")}get refresh(){return this.getAttribute(\"refresh\")}set refresh(e){e?this.setAttribute(\"refresh\",e):this.removeAttribute(\"refresh\")}get shouldReloadWithMorph(){return this.src&&\"morph\"===this.refresh}get loading(){return function(t){if(\"lazy\"===t.toLowerCase())return e.lazy;return e.eager}(this.getAttribute(\"loading\")||\"\")}set loading(e){e?this.setAttribute(\"loading\",e):this.removeAttribute(\"loading\")}get disabled(){return this.hasAttribute(\"disabled\")}set disabled(e){e?this.setAttribute(\"disabled\",\"\"):this.removeAttribute(\"disabled\")}get autoscroll(){return this.hasAttribute(\"autoscroll\")}set autoscroll(e){e?this.setAttribute(\"autoscroll\",\"\"):this.removeAttribute(\"autoscroll\")}get complete(){return!this.delegate.isLoading}get isActive(){return this.ownerDocument===document&&!this.isPreview}get isPreview(){return this.ownerDocument?.documentElement?.hasAttribute(\"data-turbo-preview\")}}const s={enabled:!0,progressBarDelay:500,unvisitableExtensions:new Set([\".7z\",\".aac\",\".apk\",\".avi\",\".bmp\",\".bz2\",\".css\",\".csv\",\".deb\",\".dmg\",\".doc\",\".docx\",\".exe\",\".gif\",\".gz\",\".heic\",\".heif\",\".ico\",\".iso\",\".jpeg\",\".jpg\",\".js\",\".json\",\".m4a\",\".mkv\",\".mov\",\".mp3\",\".mp4\",\".mpeg\",\".mpg\",\".msi\",\".ogg\",\".ogv\",\".pdf\",\".pkg\",\".png\",\".ppt\",\".pptx\",\".rar\",\".rtf\",\".svg\",\".tar\",\".tif\",\".tiff\",\".txt\",\".wav\",\".webm\",\".webp\",\".wma\",\".wmv\",\".xls\",\".xlsx\",\".xml\",\".zip\"])};function r(e){if(\"false\"==e.getAttribute(\"data-turbo-eval\"))return e;{const t=document.createElement(\"script\"),s=w();return s&&(t.nonce=s),t.textContent=e.textContent,t.async=!1,function(e,t){for(const{name:s,value:r}of t.attributes)e.setAttribute(s,r)}(t,e),t}}function i(e,{target:t,cancelable:s,detail:r}={}){const i=new CustomEvent(e,{cancelable:s,bubbles:!0,composed:!0,detail:r});return t&&t.isConnected?t.dispatchEvent(i):document.documentElement.dispatchEvent(i),i}function n(e){e.preventDefault(),e.stopImmediatePropagation()}function o(){return\"hidden\"===document.visibilityState?c():a()}function a(){return new Promise((e=>requestAnimationFrame((()=>e()))))}function c(){return new Promise((e=>setTimeout((()=>e()),0)))}function l(e=\"\"){return(new DOMParser).parseFromString(e,\"text/html\")}function h(e,...t){const s=function(e,t){return e.reduce(((e,s,r)=>e+s+(null==t[r]?\"\":t[r])),\"\")}(e,t).replace(/^\\n/,\"\").split(\"\\n\"),r=s[0].match(/^\\s+/),i=r?r[0].length:0;return s.map((e=>e.slice(i))).join(\"\\n\")}function d(){return Array.from({length:36}).map(((e,t)=>8==t||13==t||18==t||23==t?\"-\":14==t?\"4\":19==t?(Math.floor(4*Math.random())+8).toString(16):Math.floor(16*Math.random()).toString(16))).join(\"\")}function u(e,...t){for(const s of t.map((t=>t?.getAttribute(e))))if(\"string\"==typeof s)return s;return null}function m(...e){for(const t of e)\"turbo-frame\"==t.localName&&t.setAttribute(\"busy\",\"\"),t.setAttribute(\"aria-busy\",\"true\")}function p(...e){for(const t of e)\"turbo-frame\"==t.localName&&t.removeAttribute(\"busy\"),t.removeAttribute(\"aria-busy\")}function f(e,t=2e3){return new Promise((s=>{const r=()=>{e.removeEventListener(\"error\",r),e.removeEventListener(\"load\",r),s()};e.addEventListener(\"load\",r,{once:!0}),e.addEventListener(\"error\",r,{once:!0}),setTimeout(s,t)}))}function g(e){switch(e){case\"replace\":return history.replaceState;case\"advance\":case\"restore\":return history.pushState}}function b(...e){const t=u(\"data-turbo-action\",...e);return function(e){return\"advance\"==e||\"replace\"==e||\"restore\"==e}(t)?t:null}function v(e){return document.querySelector(`meta[name=\"${e}\"]`)}function S(e){const t=v(e);return t&&t.content}function w(){const e=v(\"csp-nonce\");if(e){const{nonce:t,content:s}=e;return\"\"==t?s:t}}function y(e,t){if(e instanceof Element)return e.closest(t)||y(e.assignedSlot||e.getRootNode()?.host,t)}function E(e){return!!e&&null==e.closest(\"[inert], :disabled, [hidden], details:not([open]), dialog:not([open])\")&&\"function\"==typeof e.focus}function A(e){return Array.from(e.querySelectorAll(\"[autofocus]\")).find(E)}function R(e){if(\"_blank\"===e)return!1;if(e){for(const t of document.getElementsByName(e))if(t instanceof HTMLIFrameElement)return!1;return!0}return!0}function T(e){const t=y(e,\"a[href], a[xlink\\\\:href]\");if(!t)return null;if(t.href.startsWith(\"#\"))return null;if(t.hasAttribute(\"download\"))return null;const s=t.getAttribute(\"target\");return s&&\"_self\"!==s?null:t}const L={\"aria-disabled\":{beforeSubmit:e=>{e.setAttribute(\"aria-disabled\",\"true\"),e.addEventListener(\"click\",n)},afterSubmit:e=>{e.removeAttribute(\"aria-disabled\"),e.removeEventListener(\"click\",n)}},disabled:{beforeSubmit:e=>e.disabled=!0,afterSubmit:e=>e.disabled=!1}};const C=new class{#e=null;constructor(e){Object.assign(this,e)}get submitter(){return this.#e}set submitter(e){this.#e=L[e]||e}}({mode:\"on\",submitter:\"disabled\"}),P={drive:s,forms:C};function k(e){return new URL(e.toString(),document.baseURI)}function M(e){let t;return e.hash?e.hash.slice(1):(t=e.href.match(/#(.*)$/))?t[1]:void 0}function F(e,t){return k(t?.getAttribute(\"formaction\")||e.getAttribute(\"action\")||e.action)}function I(e){return(function(e){return function(e){return e.pathname.split(\"/\").slice(1)}(e).slice(-1)[0]}(e).match(/\\.[^.]*$/)||[])[0]||\"\"}function q(e,t){return function(e,t){const s=O(t.origin+t.pathname);return O(e.href)===s||e.href.startsWith(s)}(e,t)&&!P.drive.unvisitableExtensions.has(I(e))}function H(e){return k(e.getAttribute(\"href\")||\"\")}function B(e){return function(e){const t=M(e);return null!=t?e.href.slice(0,-(t.length+1)):e.href}(e)}function N(e,t){return k(e).href==k(t).href}function O(e){return e.endsWith(\"/\")?e:e+\"/\"}class D{constructor(e){this.response=e}get succeeded(){return this.response.ok}get failed(){return!this.succeeded}get clientError(){return this.statusCode>=400&&this.statusCode<=499}get serverError(){return this.statusCode>=500&&this.statusCode<=599}get redirected(){return this.response.redirected}get location(){return k(this.response.url)}get isHTML(){return this.contentType&&this.contentType.match(/^(?:text\\/([^\\s;,]+\\b)?html|application\\/xhtml\\+xml)\\b/)}get statusCode(){return this.response.status}get contentType(){return this.header(\"Content-Type\")}get responseText(){return this.response.clone().text()}get responseHTML(){return this.isHTML?this.response.clone().text():Promise.resolve(void 0)}header(e){return this.response.headers.get(e)}}class x extends Set{constructor(e){super(),this.maxSize=e}add(e){if(this.size>=this.maxSize){const e=this.values().next().value;this.delete(e)}super.add(e)}}const V=new x(20);function W(e,t={}){const s=new Headers(t.headers||{}),r=d();return V.add(r),s.append(\"X-Turbo-Request-Id\",r),window.fetch(e,{...t,headers:s})}function U(e){switch(e.toLowerCase()){case\"get\":return _.get;case\"post\":return _.post;case\"put\":return _.put;case\"patch\":return _.patch;case\"delete\":return _.delete}}const _={get:\"get\",post:\"post\",put:\"put\",patch:\"patch\",delete:\"delete\"};function $(e){switch(e.toLowerCase()){case j.multipart:return j.multipart;case j.plain:return j.plain;default:return j.urlEncoded}}const j={urlEncoded:\"application/x-www-form-urlencoded\",multipart:\"multipart/form-data\",plain:\"text/plain\"};class z{abortController=new AbortController;#t=e=>{};constructor(e,t,s,r=new URLSearchParams,i=null,n=j.urlEncoded){const[o,a]=K(k(s),t,r,n);this.delegate=e,this.url=o,this.target=i,this.fetchOptions={credentials:\"same-origin\",redirect:\"follow\",method:t.toUpperCase(),headers:{...this.defaultHeaders},body:a,signal:this.abortSignal,referrer:this.delegate.referrer?.href},this.enctype=n}get method(){return this.fetchOptions.method}set method(e){const t=this.isSafe?this.url.searchParams:this.fetchOptions.body||new FormData,s=U(e)||_.get;this.url.search=\"\";const[r,i]=K(this.url,s,t,this.enctype);this.url=r,this.fetchOptions.body=i,this.fetchOptions.method=s.toUpperCase()}get headers(){return this.fetchOptions.headers}set headers(e){this.fetchOptions.headers=e}get body(){return this.isSafe?this.url.searchParams:this.fetchOptions.body}set body(e){this.fetchOptions.body=e}get location(){return this.url}get params(){return this.url.searchParams}get entries(){return this.body?Array.from(this.body.entries()):[]}cancel(){this.abortController.abort()}async perform(){const{fetchOptions:e}=this;this.delegate.prepareRequest(this);const t=await this.#s(e);try{this.delegate.requestStarted(this),t.detail.fetchRequest?this.response=t.detail.fetchRequest.response:this.response=W(this.url.href,e);const s=await this.response;return await this.receive(s)}catch(e){if(\"AbortError\"!==e.name)throw this.#r(e)&&this.delegate.requestErrored(this,e),e}finally{this.delegate.requestFinished(this)}}async receive(e){const t=new D(e);return i(\"turbo:before-fetch-response\",{cancelable:!0,detail:{fetchResponse:t},target:this.target}).defaultPrevented?this.delegate.requestPreventedHandlingResponse(this,t):t.succeeded?this.delegate.requestSucceededWithResponse(this,t):this.delegate.requestFailedWithResponse(this,t),t}get defaultHeaders(){return{Accept:\"text/html, application/xhtml+xml\"}}get isSafe(){return G(this.method)}get abortSignal(){return this.abortController.signal}acceptResponseType(e){this.headers.Accept=[e,this.headers.Accept].join(\", \")}async#s(e){const t=new Promise((e=>this.#t=e)),s=i(\"turbo:before-fetch-request\",{cancelable:!0,detail:{fetchOptions:e,url:this.url,resume:this.#t},target:this.target});return this.url=s.detail.url,s.defaultPrevented&&await t,s}#r(e){return!i(\"turbo:fetch-request-error\",{target:this.target,cancelable:!0,detail:{request:this,error:e}}).defaultPrevented}}function G(e){return U(e)==_.get}function K(e,t,s,r){const i=Array.from(s).length>0?new URLSearchParams(J(s)):e.searchParams;return G(t)?[X(e,i),null]:r==j.urlEncoded?[e,i]:[e,s]}function J(e){const t=[];for(const[s,r]of e)r instanceof File||t.push([s,r]);return t}function X(e,t){const s=new URLSearchParams(J(t));return e.search=s.toString(),e}class Y{started=!1;constructor(e,t){this.delegate=e,this.element=t,this.intersectionObserver=new IntersectionObserver(this.intersect)}start(){this.started||(this.started=!0,this.intersectionObserver.observe(this.element))}stop(){this.started&&(this.started=!1,this.intersectionObserver.unobserve(this.element))}intersect=e=>{const t=e.slice(-1)[0];t?.isIntersecting&&this.delegate.elementAppearedInViewport(this.element)}}class Q{static contentType=\"text/vnd.turbo-stream.html\";static wrap(e){return\"string\"==typeof e?new this(function(e){const t=document.createElement(\"template\");return t.innerHTML=e,t.content}(e)):e}constructor(e){this.fragment=function(e){for(const t of e.querySelectorAll(\"turbo-stream\")){const e=document.importNode(t,!0);for(const t of e.templateElement.content.querySelectorAll(\"script\"))t.replaceWith(r(t));t.replaceWith(e)}return e}(e)}}const Z=e=>e;class ee{keys=[];entries={};#i;constructor(e,t=Z){this.size=e,this.#i=t}has(e){return this.#i(e)in this.entries}get(e){if(this.has(e)){const t=this.read(e);return this.touch(e),t}}put(e,t){return this.write(e,t),this.touch(e),t}clear(){for(const e of Object.keys(this.entries))this.evict(e)}read(e){return this.entries[this.#i(e)]}write(e,t){this.entries[this.#i(e)]=t}touch(e){e=this.#i(e);const t=this.keys.indexOf(e);t>-1&&this.keys.splice(t,1),this.keys.unshift(e),this.trim()}trim(){for(const e of this.keys.splice(this.size))this.evict(e)}evict(e){delete this.entries[e]}}const te=1e4,se=new class extends ee{#n=null;#o={};constructor(e=1,t=100){super(e,B),this.prefetchDelay=t}putLater(e,t,s){this.#n=setTimeout((()=>{t.perform(),this.put(e,t,s),this.#n=null}),this.prefetchDelay)}put(e,t,s=te){super.put(e,t),this.#o[B(e)]=new Date((new Date).getTime()+s)}clear(){super.clear(),this.#n&&clearTimeout(this.#n)}evict(e){super.evict(e),delete this.#o[e]}has(e){if(super.has(e)){const t=this.#o[B(e)];return t&&t>Date.now()}return!1}},re={initialized:\"initialized\",requesting:\"requesting\",waiting:\"waiting\",receiving:\"receiving\",stopping:\"stopping\",stopped:\"stopped\"};class ie{state=re.initialized;static confirmMethod(e){return Promise.resolve(confirm(e))}constructor(e,t,s,r=!1){const i=function(e,t){const s=t?.getAttribute(\"formmethod\")||e.getAttribute(\"method\")||\"\";return U(s.toLowerCase())||_.get}(t,s),n=function(e,t){const s=k(e);G(t)&&(s.search=\"\");return s}(function(e,t){const s=\"string\"==typeof e.action?e.action:null;return t?.hasAttribute(\"formaction\")?t.getAttribute(\"formaction\")||\"\":e.getAttribute(\"action\")||s||\"\"}(t,s),i),o=function(e,t){const s=new FormData(e),r=t?.getAttribute(\"name\"),i=t?.getAttribute(\"value\");r&&s.append(r,i||\"\");return s}(t,s),a=function(e,t){return $(t?.getAttribute(\"formenctype\")||e.enctype)}(t,s);this.delegate=e,this.formElement=t,this.submitter=s,this.fetchRequest=new z(this,i,n,o,t,a),this.mustRedirect=r}get method(){return this.fetchRequest.method}set method(e){this.fetchRequest.method=e}get action(){return this.fetchRequest.url.toString()}set action(e){this.fetchRequest.url=k(e)}get body(){return this.fetchRequest.body}get enctype(){return this.fetchRequest.enctype}get isSafe(){return this.fetchRequest.isSafe}get location(){return this.fetchRequest.url}async start(){const{initialized:e,requesting:t}=re,s=u(\"data-turbo-confirm\",this.submitter,this.formElement);if(\"string\"==typeof s){const e=\"function\"==typeof P.forms.confirm?P.forms.confirm:ie.confirmMethod;if(!await e(s,this.formElement,this.submitter))return}if(this.state==e)return this.state=t,this.fetchRequest.perform()}stop(){const{stopping:e,stopped:t}=re;if(this.state!=e&&this.state!=t)return this.state=e,this.fetchRequest.cancel(),!0}prepareRequest(e){if(!e.isSafe){const t=function(e){if(null!=e){const t=(document.cookie?document.cookie.split(\"; \"):[]).find((t=>t.startsWith(e)));if(t){const e=t.split(\"=\").slice(1).join(\"=\");return e?decodeURIComponent(e):void 0}}}(S(\"csrf-param\"))||S(\"csrf-token\");t&&(e.headers[\"X-CSRF-Token\"]=t)}this.requestAcceptsTurboStreamResponse(e)&&e.acceptResponseType(Q.contentType)}requestStarted(e){this.state=re.waiting,this.submitter&&P.forms.submitter.beforeSubmit(this.submitter),this.setSubmitsWith(),m(this.formElement),i(\"turbo:submit-start\",{target:this.formElement,detail:{formSubmission:this}}),this.delegate.formSubmissionStarted(this)}requestPreventedHandlingResponse(e,t){se.clear(),this.result={success:t.succeeded,fetchResponse:t}}requestSucceededWithResponse(e,t){if(t.clientError||t.serverError)this.delegate.formSubmissionFailedWithResponse(this,t);else if(se.clear(),this.requestMustRedirect(e)&&function(e){return 200==e.statusCode&&!e.redirected}(t)){const e=new Error(\"Form responses must redirect to another location\");this.delegate.formSubmissionErrored(this,e)}else this.state=re.receiving,this.result={success:!0,fetchResponse:t},this.delegate.formSubmissionSucceededWithResponse(this,t)}requestFailedWithResponse(e,t){this.result={success:!1,fetchResponse:t},this.delegate.formSubmissionFailedWithResponse(this,t)}requestErrored(e,t){this.result={success:!1,error:t},this.delegate.formSubmissionErrored(this,t)}requestFinished(e){this.state=re.stopped,this.submitter&&P.forms.submitter.afterSubmit(this.submitter),this.resetSubmitterText(),p(this.formElement),i(\"turbo:submit-end\",{target:this.formElement,detail:{formSubmission:this,...this.result}}),this.delegate.formSubmissionFinished(this)}setSubmitsWith(){if(this.submitter&&this.submitsWith)if(this.submitter.matches(\"button\"))this.originalSubmitText=this.submitter.innerHTML,this.submitter.innerHTML=this.submitsWith;else if(this.submitter.matches(\"input\")){const e=this.submitter;this.originalSubmitText=e.value,e.value=this.submitsWith}}resetSubmitterText(){if(this.submitter&&this.originalSubmitText)if(this.submitter.matches(\"button\"))this.submitter.innerHTML=this.originalSubmitText;else if(this.submitter.matches(\"input\")){this.submitter.value=this.originalSubmitText}}requestMustRedirect(e){return!e.isSafe&&this.mustRedirect}requestAcceptsTurboStreamResponse(e){return!e.isSafe||function(e,...t){return t.some((t=>t&&t.hasAttribute(e)))}(\"data-turbo-stream\",this.submitter,this.formElement)}get submitsWith(){return this.submitter?.getAttribute(\"data-turbo-submits-with\")}}class ne{constructor(e){this.element=e}get activeElement(){return this.element.ownerDocument.activeElement}get children(){return[...this.element.children]}hasAnchor(e){return null!=this.getElementForAnchor(e)}getElementForAnchor(e){return e?this.element.querySelector(`[id='${e}'], a[name='${e}']`):null}get isConnected(){return this.element.isConnected}get firstAutofocusableElement(){return A(this.element)}get permanentElements(){return ae(this.element)}getPermanentElementById(e){return oe(this.element,e)}getPermanentElementMapForSnapshot(e){const t={};for(const s of this.permanentElements){const{id:r}=s,i=e.getPermanentElementById(r);i&&(t[r]=[s,i])}return t}}function oe(e,t){return e.querySelector(`#${t}[data-turbo-permanent]`)}function ae(e){return e.querySelectorAll(\"[id][data-turbo-permanent]\")}class ce{started=!1;constructor(e,t){this.delegate=e,this.eventTarget=t}start(){this.started||(this.eventTarget.addEventListener(\"submit\",this.submitCaptured,!0),this.started=!0)}stop(){this.started&&(this.eventTarget.removeEventListener(\"submit\",this.submitCaptured,!0),this.started=!1)}submitCaptured=()=>{this.eventTarget.removeEventListener(\"submit\",this.submitBubbled,!1),this.eventTarget.addEventListener(\"submit\",this.submitBubbled,!1)};submitBubbled=e=>{if(!e.defaultPrevented){const t=e.target instanceof HTMLFormElement?e.target:void 0,s=e.submitter||void 0;t&&function(e,t){const s=t?.getAttribute(\"formmethod\")||e.getAttribute(\"method\");return\"dialog\"!=s}(t,s)&&function(e,t){const s=t?.getAttribute(\"formtarget\")||e.getAttribute(\"target\");return R(s)}(t,s)&&this.delegate.willSubmitForm(t,s)&&(e.preventDefault(),e.stopImmediatePropagation(),this.delegate.formSubmitted(t,s))}}}class le{#a=e=>{};#c=e=>{};constructor(e,t){this.delegate=e,this.element=t}scrollToAnchor(e){const t=this.snapshot.getElementForAnchor(e);t?(this.focusElement(t),this.scrollToElement(t)):this.scrollToPosition({x:0,y:0})}scrollToAnchorFromLocation(e){this.scrollToAnchor(M(e))}scrollToElement(e){e.scrollIntoView()}focusElement(e){e instanceof HTMLElement&&(e.hasAttribute(\"tabindex\")?e.focus():(e.setAttribute(\"tabindex\",\"-1\"),e.focus(),e.removeAttribute(\"tabindex\")))}scrollToPosition({x:e,y:t}){this.scrollRoot.scrollTo(e,t)}scrollToTop(){this.scrollToPosition({x:0,y:0})}get scrollRoot(){return window}async render(e){const{isPreview:t,shouldRender:s,willRender:r,newSnapshot:i}=e,n=r;if(s)try{this.renderPromise=new Promise((e=>this.#a=e)),this.renderer=e,await this.prepareToRenderSnapshot(e);const s=new Promise((e=>this.#c=e)),r={resume:this.#c,render:this.renderer.renderElement,renderMethod:this.renderer.renderMethod};this.delegate.allowsImmediateRender(i,r)||await s,await this.renderSnapshot(e),this.delegate.viewRenderedSnapshot(i,t,this.renderer.renderMethod),this.delegate.preloadOnLoadLinksForView(this.element),this.finishRenderingSnapshot(e)}finally{delete this.renderer,this.#a(void 0),delete this.renderPromise}else n&&this.invalidate(e.reloadReason)}invalidate(e){this.delegate.viewInvalidated(e)}async prepareToRenderSnapshot(e){this.markAsPreview(e.isPreview),await e.prepareToRender()}markAsPreview(e){e?this.element.setAttribute(\"data-turbo-preview\",\"\"):this.element.removeAttribute(\"data-turbo-preview\")}markVisitDirection(e){this.element.setAttribute(\"data-turbo-visit-direction\",e)}unmarkVisitDirection(){this.element.removeAttribute(\"data-turbo-visit-direction\")}async renderSnapshot(e){await e.render()}finishRenderingSnapshot(e){e.finishRendering()}}class he extends le{missing(){this.element.innerHTML='<strong class=\"turbo-frame-error\">Content missing</strong>'}get snapshot(){return new ne(this.element)}}class de{constructor(e,t){this.delegate=e,this.element=t}start(){this.element.addEventListener(\"click\",this.clickBubbled),document.addEventListener(\"turbo:click\",this.linkClicked),document.addEventListener(\"turbo:before-visit\",this.willVisit)}stop(){this.element.removeEventListener(\"click\",this.clickBubbled),document.removeEventListener(\"turbo:click\",this.linkClicked),document.removeEventListener(\"turbo:before-visit\",this.willVisit)}clickBubbled=e=>{this.clickEventIsSignificant(e)?this.clickEvent=e:delete this.clickEvent};linkClicked=e=>{this.clickEvent&&this.clickEventIsSignificant(e)&&this.delegate.shouldInterceptLinkClick(e.target,e.detail.url,e.detail.originalEvent)&&(this.clickEvent.preventDefault(),e.preventDefault(),this.delegate.linkClickIntercepted(e.target,e.detail.url,e.detail.originalEvent)),delete this.clickEvent};willVisit=e=>{delete this.clickEvent};clickEventIsSignificant(e){const t=e.composed?e.target?.parentElement:e.target,s=T(t)||t;return s instanceof Element&&s.closest(\"turbo-frame, html\")==this.element}}class ue{started=!1;constructor(e,t){this.delegate=e,this.eventTarget=t}start(){this.started||(this.eventTarget.addEventListener(\"click\",this.clickCaptured,!0),this.started=!0)}stop(){this.started&&(this.eventTarget.removeEventListener(\"click\",this.clickCaptured,!0),this.started=!1)}clickCaptured=()=>{this.eventTarget.removeEventListener(\"click\",this.clickBubbled,!1),this.eventTarget.addEventListener(\"click\",this.clickBubbled,!1)};clickBubbled=e=>{if(e instanceof MouseEvent&&this.clickEventIsSignificant(e)){const t=T(e.composedPath&&e.composedPath()[0]||e.target);if(t&&R(t.target)){const s=H(t);this.delegate.willFollowLinkToLocation(t,s,e)&&(e.preventDefault(),this.delegate.followedLinkToLocation(t,s))}}};clickEventIsSignificant(e){return!(e.target&&e.target.isContentEditable||e.defaultPrevented||e.which>1||e.altKey||e.ctrlKey||e.metaKey||e.shiftKey)}}class me{constructor(e,t){this.delegate=e,this.linkInterceptor=new ue(this,t)}start(){this.linkInterceptor.start()}stop(){this.linkInterceptor.stop()}canPrefetchRequestToLocation(e,t){return!1}prefetchAndCacheRequestToLocation(e,t){}willFollowLinkToLocation(e,t,s){return this.delegate.willSubmitFormLinkToLocation(e,t,s)&&(e.hasAttribute(\"data-turbo-method\")||e.hasAttribute(\"data-turbo-stream\"))}followedLinkToLocation(e,t){const s=document.createElement(\"form\");for(const[e,r]of t.searchParams)s.append(Object.assign(document.createElement(\"input\"),{type:\"hidden\",name:e,value:r}));const r=Object.assign(t,{search:\"\"});s.setAttribute(\"data-turbo\",\"true\"),s.setAttribute(\"action\",r.href),s.setAttribute(\"hidden\",\"\");const i=e.getAttribute(\"data-turbo-method\");i&&s.setAttribute(\"method\",i);const n=e.getAttribute(\"data-turbo-frame\");n&&s.setAttribute(\"data-turbo-frame\",n);const o=b(e);o&&s.setAttribute(\"data-turbo-action\",o);const a=e.getAttribute(\"data-turbo-confirm\");a&&s.setAttribute(\"data-turbo-confirm\",a);e.hasAttribute(\"data-turbo-stream\")&&s.setAttribute(\"data-turbo-stream\",\"\"),this.delegate.submittedFormLinkToLocation(e,t,s),document.body.appendChild(s),s.addEventListener(\"turbo:submit-end\",(()=>s.remove()),{once:!0}),requestAnimationFrame((()=>s.requestSubmit()))}}class pe{static async preservingPermanentElements(e,t,s){const r=new this(e,t);r.enter(),await s(),r.leave()}constructor(e,t){this.delegate=e,this.permanentElementMap=t}enter(){for(const e in this.permanentElementMap){const[t,s]=this.permanentElementMap[e];this.delegate.enteringBardo(t,s),this.replaceNewPermanentElementWithPlaceholder(s)}}leave(){for(const e in this.permanentElementMap){const[t]=this.permanentElementMap[e];this.replaceCurrentPermanentElementWithClone(t),this.replacePlaceholderWithPermanentElement(t),this.delegate.leavingBardo(t)}}replaceNewPermanentElementWithPlaceholder(e){const t=function(e){const t=document.createElement(\"meta\");return t.setAttribute(\"name\",\"turbo-permanent-placeholder\"),t.setAttribute(\"content\",e.id),t}(e);e.replaceWith(t)}replaceCurrentPermanentElementWithClone(e){const t=e.cloneNode(!0);e.replaceWith(t)}replacePlaceholderWithPermanentElement(e){const t=this.getPlaceholderById(e.id);t?.replaceWith(e)}getPlaceholderById(e){return this.placeholders.find((t=>t.content==e))}get placeholders(){return[...document.querySelectorAll(\"meta[name=turbo-permanent-placeholder][content]\")]}}class fe{#l=null;static renderElement(e,t){}constructor(e,t,s,r=!0){this.currentSnapshot=e,this.newSnapshot=t,this.isPreview=s,this.willRender=r,this.renderElement=this.constructor.renderElement,this.promise=new Promise(((e,t)=>this.resolvingFunctions={resolve:e,reject:t}))}get shouldRender(){return!0}get shouldAutofocus(){return!0}get reloadReason(){}prepareToRender(){}render(){}finishRendering(){this.resolvingFunctions&&(this.resolvingFunctions.resolve(),delete this.resolvingFunctions)}async preservingPermanentElements(e){await pe.preservingPermanentElements(this,this.permanentElementMap,e)}focusFirstAutofocusableElement(){if(this.shouldAutofocus){const e=this.connectedSnapshot.firstAutofocusableElement;e&&e.focus()}}enteringBardo(e){this.#l||e.contains(this.currentSnapshot.activeElement)&&(this.#l=this.currentSnapshot.activeElement)}leavingBardo(e){e.contains(this.#l)&&this.#l instanceof HTMLElement&&(this.#l.focus(),this.#l=null)}get connectedSnapshot(){return this.newSnapshot.isConnected?this.newSnapshot:this.currentSnapshot}get currentElement(){return this.currentSnapshot.element}get newElement(){return this.newSnapshot.element}get permanentElementMap(){return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)}get renderMethod(){return\"replace\"}}class ge extends fe{static renderElement(e,t){const s=document.createRange();s.selectNodeContents(e),s.deleteContents();const r=t,i=r.ownerDocument?.createRange();i&&(i.selectNodeContents(r),e.appendChild(i.extractContents()))}constructor(e,t,s,r,i,n=!0){super(t,s,r,i,n),this.delegate=e}get shouldRender(){return!0}async render(){await o(),this.preservingPermanentElements((()=>{this.loadFrameElement()})),this.scrollFrameIntoView(),await o(),this.focusFirstAutofocusableElement(),await o(),this.activateScriptElements()}loadFrameElement(){this.delegate.willRenderFrame(this.currentElement,this.newElement),this.renderElement(this.currentElement,this.newElement)}scrollFrameIntoView(){if(this.currentElement.autoscroll||this.newElement.autoscroll){const s=this.currentElement.firstElementChild,r=(e=this.currentElement.getAttribute(\"data-autoscroll-block\"),t=\"end\",\"end\"==e||\"start\"==e||\"center\"==e||\"nearest\"==e?e:t),i=function(e,t){return\"auto\"==e||\"smooth\"==e?e:t}(this.currentElement.getAttribute(\"data-autoscroll-behavior\"),\"auto\");if(s)return s.scrollIntoView({block:r,behavior:i}),!0}var e,t;return!1}activateScriptElements(){for(const e of this.newScriptElements){const t=r(e);e.replaceWith(t)}}get newScriptElements(){return this.currentElement.querySelectorAll(\"script\")}}var be=function(){const e=()=>{},t={morphStyle:\"outerHTML\",callbacks:{beforeNodeAdded:e,afterNodeAdded:e,beforeNodeMorphed:e,afterNodeMorphed:e,beforeNodeRemoved:e,afterNodeRemoved:e,beforeAttributeUpdated:e},head:{style:\"merge\",shouldPreserve:e=>\"true\"===e.getAttribute(\"im-preserve\"),shouldReAppend:e=>\"true\"===e.getAttribute(\"im-re-append\"),shouldRemove:e,afterHeadMorphed:e},restoreFocus:!0};const s=function(){function e(e,t,s,i){if(!1===i.callbacks.beforeNodeAdded(t))return null;if(i.idMap.has(t)){const n=document.createElement(t.tagName);return e.insertBefore(n,s),r(n,t,i),i.callbacks.afterNodeAdded(n),n}{const r=document.importNode(t,!0);return e.insertBefore(r,s),i.callbacks.afterNodeAdded(r),r}}const t=function(){function e(e,t,s){let r=e.idMap.get(t),i=e.idMap.get(s);if(!i||!r)return!1;for(const e of r)if(i.has(e))return!0;return!1}function t(e,t){const s=e,r=t;return s.nodeType===r.nodeType&&s.tagName===r.tagName&&(!s.getAttribute?.(\"id\")||s.getAttribute?.(\"id\")===r.getAttribute?.(\"id\"))}return function(s,r,i,n){let o=null,a=r.nextSibling,c=0,l=i;for(;l&&l!=n;){if(t(l,r)){if(e(s,l,r))return l;null===o&&(s.idMap.has(l)||(o=l))}if(null===o&&a&&t(l,a)&&(c++,a=a.nextSibling,c>=2&&(o=void 0)),s.activeElementAndParents.includes(l))break;l=l.nextSibling}return o||null}}();function s(e,t){if(e.idMap.has(t))o(e.pantry,t,null);else{if(!1===e.callbacks.beforeNodeRemoved(t))return;t.parentNode?.removeChild(t),e.callbacks.afterNodeRemoved(t)}}function i(e,t,r){let i=t;for(;i&&i!==r;){let t=i;i=i.nextSibling,s(e,t)}return i}function n(e,t,s,r){const i=r.target.getAttribute?.(\"id\")===t&&r.target||r.target.querySelector(`[id=\"${t}\"]`)||r.pantry.querySelector(`[id=\"${t}\"]`);return function(e,t){const s=e.getAttribute(\"id\");for(;e=e.parentNode;){let r=t.idMap.get(e);r&&(r.delete(s),r.size||t.idMap.delete(e))}}(i,r),o(e,i,s),i}function o(e,t,s){if(e.moveBefore)try{e.moveBefore(t,s)}catch(r){e.insertBefore(t,s)}else e.insertBefore(t,s)}return function(o,a,c,l=null,h=null){a instanceof HTMLTemplateElement&&c instanceof HTMLTemplateElement&&(a=a.content,c=c.content),l||=a.firstChild;for(const s of c.childNodes){if(l&&l!=h){const e=t(o,s,l,h);if(e){e!==l&&i(o,l,e),r(e,s,o),l=e.nextSibling;continue}}if(s instanceof Element){const e=s.getAttribute(\"id\");if(o.persistentIds.has(e)){const t=n(a,e,l,o);r(t,s,o),l=t.nextSibling;continue}}const c=e(a,s,l,o);c&&(l=c.nextSibling)}for(;l&&l!=h;){const e=l;l=l.nextSibling,s(o,e)}}}(),r=function(){function e(e,s,r,i){const n=s[r];if(n!==e[r]){const o=t(r,e,\"update\",i);o||(e[r]=s[r]),n?o||e.setAttribute(r,\"\"):t(r,e,\"remove\",i)||e.removeAttribute(r)}}function t(e,t,s,r){return!(\"value\"!==e||!r.ignoreActiveValue||t!==document.activeElement)||!1===r.callbacks.beforeAttributeUpdated(e,t,s)}function r(e,t){return!!t.ignoreActiveValue&&e===document.activeElement&&e!==document.body}return function(n,o,a){return a.ignoreActive&&n===document.activeElement?null:(!1===a.callbacks.beforeNodeMorphed(n,o)||(n instanceof HTMLHeadElement&&a.head.ignore||(n instanceof HTMLHeadElement&&\"morph\"!==a.head.style?i(n,o,a):(!function(s,i,n){let o=i.nodeType;if(1===o){const o=s,a=i,c=o.attributes,l=a.attributes;for(const e of l)t(e.name,o,\"update\",n)||o.getAttribute(e.name)!==e.value&&o.setAttribute(e.name,e.value);for(let e=c.length-1;0<=e;e--){const s=c[e];if(s&&!a.hasAttribute(s.name)){if(t(s.name,o,\"remove\",n))continue;o.removeAttribute(s.name)}}r(o,n)||function(s,r,i){if(s instanceof HTMLInputElement&&r instanceof HTMLInputElement&&\"file\"!==r.type){let n=r.value,o=s.value;e(s,r,\"checked\",i),e(s,r,\"disabled\",i),r.hasAttribute(\"value\")?o!==n&&(t(\"value\",s,\"update\",i)||(s.setAttribute(\"value\",n),s.value=n)):t(\"value\",s,\"remove\",i)||(s.value=\"\",s.removeAttribute(\"value\"))}else if(s instanceof HTMLOptionElement&&r instanceof HTMLOptionElement)e(s,r,\"selected\",i);else if(s instanceof HTMLTextAreaElement&&r instanceof HTMLTextAreaElement){let e=r.value,n=s.value;if(t(\"value\",s,\"update\",i))return;e!==n&&(s.value=e),s.firstChild&&s.firstChild.nodeValue!==e&&(s.firstChild.nodeValue=e)}}(o,a,n)}8!==o&&3!==o||s.nodeValue!==i.nodeValue&&(s.nodeValue=i.nodeValue)}(n,o,a),r(n,a)||s(a,n,o))),a.callbacks.afterNodeMorphed(n,o)),n)}}();function i(e,t,s){let r=[],i=[],n=[],o=[],a=new Map;for(const e of t.children)a.set(e.outerHTML,e);for(const t of e.children){let e=a.has(t.outerHTML),r=s.head.shouldReAppend(t),c=s.head.shouldPreserve(t);e||c?r?i.push(t):(a.delete(t.outerHTML),n.push(t)):\"append\"===s.head.style?r&&(i.push(t),o.push(t)):!1!==s.head.shouldRemove(t)&&i.push(t)}o.push(...a.values());let c=[];for(const t of o){let i=document.createRange().createContextualFragment(t.outerHTML).firstChild;if(!1!==s.callbacks.beforeNodeAdded(i)){if(\"href\"in i&&i.href||\"src\"in i&&i.src){let e,t=new Promise((function(t){e=t}));i.addEventListener(\"load\",(function(){e()})),c.push(t)}e.appendChild(i),s.callbacks.afterNodeAdded(i),r.push(i)}}for(const t of i)!1!==s.callbacks.beforeNodeRemoved(t)&&(e.removeChild(t),s.callbacks.afterNodeRemoved(t));return s.head.afterHeadMorphed(e,{added:r,kept:n,removed:i}),c}const n=function(){function e(){const e=document.createElement(\"div\");return e.hidden=!0,document.body.insertAdjacentElement(\"afterend\",e),e}function s(e){let t=[],s=document.activeElement;if(\"BODY\"!==s?.tagName&&e.contains(s))for(;s&&(t.push(s),s!==e);)s=s.parentElement;return t}function r(e){let t=Array.from(e.querySelectorAll(\"[id]\"));return e.getAttribute?.(\"id\")&&t.push(e),t}function i(e,t,s,r){for(const i of r){const r=i.getAttribute(\"id\");if(t.has(r)){let t=i;for(;t;){let i=e.get(t);if(null==i&&(i=new Set,e.set(t,i)),i.add(r),t===s)break;t=t.parentElement}}}}return function(n,o,a){const{persistentIds:c,idMap:l}=function(e,t){const s=r(e),n=r(t),o=function(e,t){let s=new Set,r=new Map;for(const{id:t,tagName:i}of e)r.has(t)?s.add(t):r.set(t,i);let i=new Set;for(const{id:e,tagName:n}of t)i.has(e)?s.add(e):r.get(e)===n&&i.add(e);for(const e of s)i.delete(e);return i}(s,n);let a=new Map;i(a,o,e,s);const c=t.__idiomorphRoot||t;return i(a,o,c,n),{persistentIds:o,idMap:a}}(n,o),h=function(e){let s=Object.assign({},t);return Object.assign(s,e),s.callbacks=Object.assign({},t.callbacks,e.callbacks),s.head=Object.assign({},t.head,e.head),s}(a),d=h.morphStyle||\"outerHTML\";if(![\"innerHTML\",\"outerHTML\"].includes(d))throw`Do not understand how to morph style ${d}`;return{target:n,newContent:o,config:h,morphStyle:d,ignoreActive:h.ignoreActive,ignoreActiveValue:h.ignoreActiveValue,restoreFocus:h.restoreFocus,idMap:l,persistentIds:c,pantry:e(),activeElementAndParents:s(n),callbacks:h.callbacks,head:h.head}}}(),{normalizeElement:o,normalizeParent:a}=function(){const e=new WeakSet;class t{constructor(e){this.originalNode=e,this.realParentNode=e.parentNode,this.previousSibling=e.previousSibling,this.nextSibling=e.nextSibling}get childNodes(){const e=[];let t=this.previousSibling?this.previousSibling.nextSibling:this.realParentNode.firstChild;for(;t&&t!=this.nextSibling;)e.push(t),t=t.nextSibling;return e}querySelectorAll(e){return this.childNodes.reduce(((t,s)=>{if(s instanceof Element){s.matches(e)&&t.push(s);const r=s.querySelectorAll(e);for(let e=0;e<r.length;e++)t.push(r[e])}return t}),[])}insertBefore(e,t){return this.realParentNode.insertBefore(e,t)}moveBefore(e,t){return this.realParentNode.moveBefore(e,t)}get __idiomorphRoot(){return this.originalNode}}return{normalizeElement:function(e){return e instanceof Document?e.documentElement:e},normalizeParent:function s(r){if(null==r)return document.createElement(\"div\");if(\"string\"==typeof r)return s(function(t){let s=new DOMParser,r=t.replace(/<svg(\\s[^>]*>|>)([\\s\\S]*?)<\\/svg>/gim,\"\");if(r.match(/<\\/html>/)||r.match(/<\\/head>/)||r.match(/<\\/body>/)){let i=s.parseFromString(t,\"text/html\");if(r.match(/<\\/html>/))return e.add(i),i;{let t=i.firstChild;return t&&e.add(t),t}}{let r=s.parseFromString(\"<body><template>\"+t+\"</template></body>\",\"text/html\").body.querySelector(\"template\").content;return e.add(r),r}}(r));if(e.has(r))return r;if(r instanceof Node){if(r.parentNode)return new t(r);{const e=document.createElement(\"div\");return e.append(r),e}}{const e=document.createElement(\"div\");for(const t of[...r])e.append(t);return e}}}}();return{morph:function(e,t,r={}){e=o(e);const c=a(t),l=n(e,c,r),h=function(e,t){if(!e.config.restoreFocus)return t();let s=document.activeElement;if(!(s instanceof HTMLInputElement||s instanceof HTMLTextAreaElement))return t();const{id:r,selectionStart:i,selectionEnd:n}=s,o=t();r&&r!==document.activeElement?.getAttribute(\"id\")&&(s=e.target.querySelector(`[id=\"${r}\"]`),s?.focus());s&&!s.selectionEnd&&n&&s.setSelectionRange(i,n);return o}(l,(()=>function(e,t,s,r){if(e.head.block){const n=t.querySelector(\"head\"),o=s.querySelector(\"head\");if(n&&o){const t=i(n,o,e);return Promise.all(t).then((()=>{const t=Object.assign(e,{head:{block:!1,ignore:!0}});return r(t)}))}}return r(e)}(l,e,c,(t=>\"innerHTML\"===t.morphStyle?(s(t,e,c),Array.from(e.childNodes)):function(e,t,r){const i=a(t);return s(e,i,r,t,t.nextSibling),Array.from(i.childNodes)}(t,e,c)))));return l.pantry.remove(),h},defaults:t}}();function ve(e,t,{callbacks:s,...r}={}){be.morph(e,t,{...r,callbacks:new Ee(s)})}function Se(e,t,s={}){ve(e,t.childNodes,{...s,morphStyle:\"innerHTML\"})}function we(e,s){return e instanceof t&&e.shouldReloadWithMorph&&(!s||function(e,t){return t instanceof Element&&\"TURBO-FRAME\"===t.nodeName&&e.id===t.id&&(!t.getAttribute(\"src\")||N(e.src,t.getAttribute(\"src\")))}(e,s))&&!e.closest(\"[data-turbo-permanent]\")}function ye(e){return e.parentElement.closest(\"turbo-frame[src][refresh=morph]\")}class Ee{#h;constructor({beforeNodeMorphed:e}={}){this.#h=e||(()=>!0)}beforeNodeAdded=e=>!(e.id&&e.hasAttribute(\"data-turbo-permanent\")&&document.getElementById(e.id));beforeNodeMorphed=(e,t)=>{if(e instanceof Element){if(!e.hasAttribute(\"data-turbo-permanent\")&&this.#h(e,t)){return!i(\"turbo:before-morph-element\",{cancelable:!0,target:e,detail:{currentElement:e,newElement:t}}).defaultPrevented}return!1}};beforeAttributeUpdated=(e,t,s)=>!i(\"turbo:before-morph-attribute\",{cancelable:!0,target:t,detail:{attributeName:e,mutationType:s}}).defaultPrevented;beforeNodeRemoved=e=>this.beforeNodeMorphed(e);afterNodeMorphed=(e,t)=>{e instanceof Element&&i(\"turbo:morph-element\",{target:e,detail:{currentElement:e,newElement:t}})}}class Ae extends ge{static renderElement(e,t){i(\"turbo:before-frame-morph\",{target:e,detail:{currentElement:e,newElement:t}}),Se(e,t,{callbacks:{beforeNodeMorphed:(t,s)=>!we(t,s)||ye(t)!==e||(t.reload(),!1)}})}async preservingPermanentElements(e){return await e()}}class Re{static animationDuration=300;static get defaultCSS(){return h`\n      .turbo-progress-bar {\n        position: fixed;\n        display: block;\n        top: 0;\n        left: 0;\n        height: 3px;\n        background: #0076ff;\n        z-index: 2147483647;\n        transition:\n          width ${Re.animationDuration}ms ease-out,\n          opacity ${Re.animationDuration/2}ms ${Re.animationDuration/2}ms ease-in;\n        transform: translate3d(0, 0, 0);\n      }\n    `}hiding=!1;value=0;visible=!1;constructor(){this.stylesheetElement=this.createStylesheetElement(),this.progressElement=this.createProgressElement(),this.installStylesheetElement(),this.setValue(0)}show(){this.visible||(this.visible=!0,this.installProgressElement(),this.startTrickling())}hide(){this.visible&&!this.hiding&&(this.hiding=!0,this.fadeProgressElement((()=>{this.uninstallProgressElement(),this.stopTrickling(),this.visible=!1,this.hiding=!1})))}setValue(e){this.value=e,this.refresh()}installStylesheetElement(){document.head.insertBefore(this.stylesheetElement,document.head.firstChild)}installProgressElement(){this.progressElement.style.width=\"0\",this.progressElement.style.opacity=\"1\",document.documentElement.insertBefore(this.progressElement,document.body),this.refresh()}fadeProgressElement(e){this.progressElement.style.opacity=\"0\",setTimeout(e,1.5*Re.animationDuration)}uninstallProgressElement(){this.progressElement.parentNode&&document.documentElement.removeChild(this.progressElement)}startTrickling(){this.trickleInterval||(this.trickleInterval=window.setInterval(this.trickle,Re.animationDuration))}stopTrickling(){window.clearInterval(this.trickleInterval),delete this.trickleInterval}trickle=()=>{this.setValue(this.value+Math.random()/100)};refresh(){requestAnimationFrame((()=>{this.progressElement.style.width=10+90*this.value+\"%\"}))}createStylesheetElement(){const e=document.createElement(\"style\");e.type=\"text/css\",e.textContent=Re.defaultCSS;const t=w();return t&&(e.nonce=t),e}createProgressElement(){const e=document.createElement(\"div\");return e.className=\"turbo-progress-bar\",e}}class Te extends ne{detailsByOuterHTML=this.children.filter((e=>!function(e){const t=e.localName;return\"noscript\"==t}(e))).map((e=>function(e){e.hasAttribute(\"nonce\")&&e.setAttribute(\"nonce\",\"\");return e}(e))).reduce(((e,t)=>{const{outerHTML:s}=t,r=s in e?e[s]:{type:Le(t),tracked:Ce(t),elements:[]};return{...e,[s]:{...r,elements:[...r.elements,t]}}}),{});get trackedElementSignature(){return Object.keys(this.detailsByOuterHTML).filter((e=>this.detailsByOuterHTML[e].tracked)).join(\"\")}getScriptElementsNotInSnapshot(e){return this.getElementsMatchingTypeNotInSnapshot(\"script\",e)}getStylesheetElementsNotInSnapshot(e){return this.getElementsMatchingTypeNotInSnapshot(\"stylesheet\",e)}getElementsMatchingTypeNotInSnapshot(e,t){return Object.keys(this.detailsByOuterHTML).filter((e=>!(e in t.detailsByOuterHTML))).map((e=>this.detailsByOuterHTML[e])).filter((({type:t})=>t==e)).map((({elements:[e]})=>e))}get provisionalElements(){return Object.keys(this.detailsByOuterHTML).reduce(((e,t)=>{const{type:s,tracked:r,elements:i}=this.detailsByOuterHTML[t];return null!=s||r?i.length>1?[...e,...i.slice(1)]:e:[...e,...i]}),[])}getMetaValue(e){const t=this.findMetaElementByName(e);return t?t.getAttribute(\"content\"):null}findMetaElementByName(e){return Object.keys(this.detailsByOuterHTML).reduce(((t,s)=>{const{elements:[r]}=this.detailsByOuterHTML[s];return function(e,t){const s=e.localName;return\"meta\"==s&&e.getAttribute(\"name\")==t}(r,e)?r:t}),0)}}function Le(e){return function(e){const t=e.localName;return\"script\"==t}(e)?\"script\":function(e){const t=e.localName;return\"style\"==t||\"link\"==t&&\"stylesheet\"==e.getAttribute(\"rel\")}(e)?\"stylesheet\":void 0}function Ce(e){return\"reload\"==e.getAttribute(\"data-turbo-track\")}class Pe extends ne{static fromHTMLString(e=\"\"){return this.fromDocument(l(e))}static fromElement(e){return this.fromDocument(e.ownerDocument)}static fromDocument({documentElement:e,body:t,head:s}){return new this(e,t,new Te(s))}constructor(e,t,s){super(t),this.documentElement=e,this.headSnapshot=s}clone(){const e=this.element.cloneNode(!0),t=this.element.querySelectorAll(\"select\"),s=e.querySelectorAll(\"select\");for(const[e,r]of t.entries()){const t=s[e];for(const e of t.selectedOptions)e.selected=!1;for(const e of r.selectedOptions)t.options[e.index].selected=!0}for(const t of e.querySelectorAll('input[type=\"password\"]'))t.value=\"\";for(const t of e.querySelectorAll(\"noscript\"))t.remove();return new Pe(this.documentElement,e,this.headSnapshot)}get lang(){return this.documentElement.getAttribute(\"lang\")}get dir(){return this.documentElement.getAttribute(\"dir\")}get headElement(){return this.headSnapshot.element}get rootLocation(){return k(this.getSetting(\"root\")??\"/\")}get cacheControlValue(){return this.getSetting(\"cache-control\")}get isPreviewable(){return\"no-preview\"!=this.cacheControlValue}get isCacheable(){return\"no-cache\"!=this.cacheControlValue}get isVisitable(){return\"reload\"!=this.getSetting(\"visit-control\")}get prefersViewTransitions(){return(\"true\"===this.getSetting(\"view-transition\")||\"same-origin\"===this.headSnapshot.getMetaValue(\"view-transition\"))&&!window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches}get refreshMethod(){return this.getSetting(\"refresh-method\")}get refreshScroll(){return this.getSetting(\"refresh-scroll\")}getSetting(e){return this.headSnapshot.getMetaValue(`turbo-${e}`)}}class ke{#d=!1;#u=Promise.resolve();renderChange(e,t){return e&&this.viewTransitionsAvailable&&!this.#d?(this.#d=!0,this.#u=this.#u.then((async()=>{await document.startViewTransition(t).finished}))):this.#u=this.#u.then(t),this.#u}get viewTransitionsAvailable(){return document.startViewTransition}}const Me={action:\"advance\",historyChanged:!1,visitCachedSnapshot:()=>{},willRender:!0,updateHistory:!0,shouldCacheSnapshot:!0,acceptsStreamResponse:!1,refresh:{}},Fe=\"visitStart\",Ie=\"requestStart\",qe=\"requestEnd\",He=\"visitEnd\",Be=\"initialized\",Ne=\"started\",Oe=\"canceled\",De=\"failed\",xe=\"completed\",Ve=0,We=-1,Ue=-2,_e={advance:\"forward\",restore:\"back\",replace:\"none\"};class $e{identifier=d();timingMetrics={};followedRedirect=!1;historyChanged=!1;scrolled=!1;shouldCacheSnapshot=!0;acceptsStreamResponse=!1;snapshotCached=!1;state=Be;viewTransitioner=new ke;constructor(e,t,s,r={}){this.delegate=e,this.location=t,this.restorationIdentifier=s||d();const{action:i,historyChanged:n,referrer:o,snapshot:a,snapshotHTML:c,response:l,visitCachedSnapshot:h,willRender:u,updateHistory:m,shouldCacheSnapshot:p,acceptsStreamResponse:f,direction:g,refresh:b}={...Me,...r};this.action=i,this.historyChanged=n,this.referrer=o,this.snapshot=a,this.snapshotHTML=c,this.response=l,this.isPageRefresh=this.view.isPageRefresh(this),this.visitCachedSnapshot=h,this.willRender=u,this.updateHistory=m,this.scrolled=!u,this.shouldCacheSnapshot=p,this.acceptsStreamResponse=f,this.direction=g||_e[i],this.refresh=b}get adapter(){return this.delegate.adapter}get view(){return this.delegate.view}get history(){return this.delegate.history}get restorationData(){return this.history.getRestorationDataForIdentifier(this.restorationIdentifier)}start(){this.state==Be&&(this.recordTimingMetric(Fe),this.state=Ne,this.adapter.visitStarted(this),this.delegate.visitStarted(this))}cancel(){this.state==Ne&&(this.request&&this.request.cancel(),this.cancelRender(),this.state=Oe)}complete(){this.state==Ne&&(this.recordTimingMetric(He),this.adapter.visitCompleted(this),this.state=xe,this.followRedirect(),this.followedRedirect||this.delegate.visitCompleted(this))}fail(){this.state==Ne&&(this.state=De,this.adapter.visitFailed(this),this.delegate.visitCompleted(this))}changeHistory(){if(!this.historyChanged&&this.updateHistory){const e=g(this.location.href===this.referrer?.href?\"replace\":this.action);this.history.update(e,this.location,this.restorationIdentifier),this.historyChanged=!0}}issueRequest(){this.hasPreloadedResponse()?this.simulateRequest():this.shouldIssueRequest()&&!this.request&&(this.request=new z(this,_.get,this.location),this.request.perform())}simulateRequest(){this.response&&(this.startRequest(),this.recordResponse(),this.finishRequest())}startRequest(){this.recordTimingMetric(Ie),this.adapter.visitRequestStarted(this)}recordResponse(e=this.response){if(this.response=e,e){const{statusCode:t}=e;je(t)?this.adapter.visitRequestCompleted(this):this.adapter.visitRequestFailedWithStatusCode(this,t)}}finishRequest(){this.recordTimingMetric(qe),this.adapter.visitRequestFinished(this)}loadResponse(){if(this.response){const{statusCode:e,responseHTML:t}=this.response;this.render((async()=>{if(this.shouldCacheSnapshot&&this.cacheSnapshot(),this.view.renderPromise&&await this.view.renderPromise,je(e)&&null!=t){const e=Pe.fromHTMLString(t);await this.renderPageSnapshot(e,!1),this.adapter.visitRendered(this),this.complete()}else await this.view.renderError(Pe.fromHTMLString(t),this),this.adapter.visitRendered(this),this.fail()}))}}getCachedSnapshot(){const e=this.view.getCachedSnapshotForLocation(this.location)||this.getPreloadedSnapshot();if(e&&(!M(this.location)||e.hasAnchor(M(this.location)))&&(\"restore\"==this.action||e.isPreviewable))return e}getPreloadedSnapshot(){if(this.snapshotHTML)return Pe.fromHTMLString(this.snapshotHTML)}hasCachedSnapshot(){return null!=this.getCachedSnapshot()}loadCachedSnapshot(){const e=this.getCachedSnapshot();if(e){const t=this.shouldIssueRequest();this.render((async()=>{this.cacheSnapshot(),this.isPageRefresh?this.adapter.visitRendered(this):(this.view.renderPromise&&await this.view.renderPromise,await this.renderPageSnapshot(e,t),this.adapter.visitRendered(this),t||this.complete())}))}}followRedirect(){this.redirectedToLocation&&!this.followedRedirect&&this.response?.redirected&&(this.adapter.visitProposedToLocation(this.redirectedToLocation,{action:\"replace\",response:this.response,shouldCacheSnapshot:!1,willRender:!1}),this.followedRedirect=!0)}prepareRequest(e){this.acceptsStreamResponse&&e.acceptResponseType(Q.contentType)}requestStarted(){this.startRequest()}requestPreventedHandlingResponse(e,t){}async requestSucceededWithResponse(e,t){const s=await t.responseHTML,{redirected:r,statusCode:i}=t;null==s?this.recordResponse({statusCode:Ue,redirected:r}):(this.redirectedToLocation=t.redirected?t.location:void 0,this.recordResponse({statusCode:i,responseHTML:s,redirected:r}))}async requestFailedWithResponse(e,t){const s=await t.responseHTML,{redirected:r,statusCode:i}=t;null==s?this.recordResponse({statusCode:Ue,redirected:r}):this.recordResponse({statusCode:i,responseHTML:s,redirected:r})}requestErrored(e,t){this.recordResponse({statusCode:Ve,redirected:!1})}requestFinished(){this.finishRequest()}performScroll(){this.scrolled||this.view.forceReloaded||this.view.shouldPreserveScrollPosition(this)||(\"restore\"==this.action?this.scrollToRestoredPosition()||this.scrollToAnchor()||this.view.scrollToTop():this.scrollToAnchor()||this.view.scrollToTop(),this.scrolled=!0)}scrollToRestoredPosition(){const{scrollPosition:e}=this.restorationData;if(e)return this.view.scrollToPosition(e),!0}scrollToAnchor(){const e=M(this.location);if(null!=e)return this.view.scrollToAnchor(e),!0}recordTimingMetric(e){this.timingMetrics[e]=(new Date).getTime()}getTimingMetrics(){return{...this.timingMetrics}}hasPreloadedResponse(){return\"object\"==typeof this.response}shouldIssueRequest(){return\"restore\"==this.action?!this.hasCachedSnapshot():this.willRender}cacheSnapshot(){this.snapshotCached||(this.view.cacheSnapshot(this.snapshot).then((e=>e&&this.visitCachedSnapshot(e))),this.snapshotCached=!0)}async render(e){this.cancelRender(),await new Promise((e=>{this.frame=\"hidden\"===document.visibilityState?setTimeout((()=>e()),0):requestAnimationFrame((()=>e()))})),await e(),delete this.frame}async renderPageSnapshot(e,t){await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(e),(async()=>{await this.view.renderPage(e,t,this.willRender,this),this.performScroll()}))}cancelRender(){this.frame&&(cancelAnimationFrame(this.frame),delete this.frame)}}function je(e){return e>=200&&e<300}class ze{progressBar=new Re;constructor(e){this.session=e}visitProposedToLocation(e,t){q(e,this.navigator.rootLocation)?this.navigator.startVisit(e,t?.restorationIdentifier||d(),t):window.location.href=e.toString()}visitStarted(e){this.location=e.location,this.redirectedToLocation=null,e.loadCachedSnapshot(),e.issueRequest()}visitRequestStarted(e){this.progressBar.setValue(0),e.hasCachedSnapshot()||\"restore\"!=e.action?this.showVisitProgressBarAfterDelay():this.showProgressBar()}visitRequestCompleted(e){e.loadResponse(),e.response.redirected&&(this.redirectedToLocation=e.redirectedToLocation)}visitRequestFailedWithStatusCode(e,t){switch(t){case Ve:case We:case Ue:return this.reload({reason:\"request_failed\",context:{statusCode:t}});default:return e.loadResponse()}}visitRequestFinished(e){}visitCompleted(e){this.progressBar.setValue(1),this.hideVisitProgressBar()}pageInvalidated(e){this.reload(e)}visitFailed(e){this.progressBar.setValue(1),this.hideVisitProgressBar()}visitRendered(e){}linkPrefetchingIsEnabledForLocation(e){return!0}formSubmissionStarted(e){this.progressBar.setValue(0),this.showFormProgressBarAfterDelay()}formSubmissionFinished(e){this.progressBar.setValue(1),this.hideFormProgressBar()}showVisitProgressBarAfterDelay(){this.visitProgressBarTimeout=window.setTimeout(this.showProgressBar,this.session.progressBarDelay)}hideVisitProgressBar(){this.progressBar.hide(),null!=this.visitProgressBarTimeout&&(window.clearTimeout(this.visitProgressBarTimeout),delete this.visitProgressBarTimeout)}showFormProgressBarAfterDelay(){null==this.formProgressBarTimeout&&(this.formProgressBarTimeout=window.setTimeout(this.showProgressBar,this.session.progressBarDelay))}hideFormProgressBar(){this.progressBar.hide(),null!=this.formProgressBarTimeout&&(window.clearTimeout(this.formProgressBarTimeout),delete this.formProgressBarTimeout)}showProgressBar=()=>{this.progressBar.show()};reload(e){i(\"turbo:reload\",{detail:e}),window.location.href=(this.redirectedToLocation||this.location)?.toString()||window.location.href}get navigator(){return this.session.navigator}}class Ge{selector=\"[data-turbo-temporary]\";started=!1;start(){this.started||(this.started=!0,addEventListener(\"turbo:before-cache\",this.removeTemporaryElements,!1))}stop(){this.started&&(this.started=!1,removeEventListener(\"turbo:before-cache\",this.removeTemporaryElements,!1))}removeTemporaryElements=e=>{for(const e of this.temporaryElements)e.remove()};get temporaryElements(){return[...document.querySelectorAll(this.selector)]}}class Ke{constructor(e,t){this.session=e,this.element=t,this.linkInterceptor=new de(this,t),this.formSubmitObserver=new ce(this,t)}start(){this.linkInterceptor.start(),this.formSubmitObserver.start()}stop(){this.linkInterceptor.stop(),this.formSubmitObserver.stop()}shouldInterceptLinkClick(e,t,s){return this.#m(e)}linkClickIntercepted(e,t,s){const r=this.#p(e);r&&r.delegate.linkClickIntercepted(e,t,s)}willSubmitForm(e,t){return null==e.closest(\"turbo-frame\")&&this.#f(e,t)&&this.#m(e,t)}formSubmitted(e,t){const s=this.#p(e,t);s&&s.delegate.formSubmitted(e,t)}#f(e,t){const s=F(e,t),r=this.element.ownerDocument.querySelector('meta[name=\"turbo-root\"]'),i=k(r?.content??\"/\");return this.#m(e,t)&&q(s,i)}#m(e,t){if(e instanceof HTMLFormElement?this.session.submissionIsNavigatable(e,t):this.session.elementIsNavigatable(e)){const s=this.#p(e,t);return!!s&&s!=e.closest(\"turbo-frame\")}return!1}#p(e,s){const r=s?.getAttribute(\"data-turbo-frame\")||e.getAttribute(\"data-turbo-frame\");if(r&&\"_top\"!=r){const e=this.element.querySelector(`#${r}:not([disabled])`);if(e instanceof t)return e}}}class Je{location;restorationIdentifier=d();restorationData={};started=!1;currentIndex=0;constructor(e){this.delegate=e}start(){this.started||(addEventListener(\"popstate\",this.onPopState,!1),this.currentIndex=history.state?.turbo?.restorationIndex||0,this.started=!0,this.replace(new URL(window.location.href)))}stop(){this.started&&(removeEventListener(\"popstate\",this.onPopState,!1),this.started=!1)}push(e,t){this.update(history.pushState,e,t)}replace(e,t){this.update(history.replaceState,e,t)}update(e,t,s=d()){e===history.pushState&&++this.currentIndex;const r={turbo:{restorationIdentifier:s,restorationIndex:this.currentIndex}};e.call(history,r,\"\",t.href),this.location=t,this.restorationIdentifier=s}getRestorationDataForIdentifier(e){return this.restorationData[e]||{}}updateRestorationData(e){const{restorationIdentifier:t}=this,s=this.restorationData[t];this.restorationData[t]={...s,...e}}assumeControlOfScrollRestoration(){this.previousScrollRestoration||(this.previousScrollRestoration=history.scrollRestoration??\"auto\",history.scrollRestoration=\"manual\")}relinquishControlOfScrollRestoration(){this.previousScrollRestoration&&(history.scrollRestoration=this.previousScrollRestoration,delete this.previousScrollRestoration)}onPopState=e=>{const{turbo:t}=e.state||{};if(this.location=new URL(window.location.href),t){const{restorationIdentifier:e,restorationIndex:s}=t;this.restorationIdentifier=e;const r=s>this.currentIndex?\"forward\":\"back\";this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(this.location,e,r),this.currentIndex=s}else this.currentIndex++,this.delegate.historyPoppedWithEmptyState(this.location)}}class Xe{started=!1;#g=null;constructor(e,t){this.delegate=e,this.eventTarget=t}start(){this.started||(\"loading\"===this.eventTarget.readyState?this.eventTarget.addEventListener(\"DOMContentLoaded\",this.#b,{once:!0}):this.#b())}stop(){this.started&&(this.eventTarget.removeEventListener(\"mouseenter\",this.#v,{capture:!0,passive:!0}),this.eventTarget.removeEventListener(\"mouseleave\",this.#S,{capture:!0,passive:!0}),this.eventTarget.removeEventListener(\"turbo:before-fetch-request\",this.#w,!0),this.started=!1)}#b=()=>{this.eventTarget.addEventListener(\"mouseenter\",this.#v,{capture:!0,passive:!0}),this.eventTarget.addEventListener(\"mouseleave\",this.#S,{capture:!0,passive:!0}),this.eventTarget.addEventListener(\"turbo:before-fetch-request\",this.#w,!0),this.started=!0};#v=e=>{if(\"false\"===S(\"turbo-prefetch\"))return;const t=e.target;if(t.matches&&t.matches(\"a[href]:not([target^=_]):not([download])\")&&this.#y(t)){const e=t,s=H(e);if(this.delegate.canPrefetchRequestToLocation(e,s)){this.#g=e;const r=new z(this,_.get,s,new URLSearchParams,t);r.fetchOptions.priority=\"low\",se.putLater(s,r,this.#E)}}};#S=e=>{e.target===this.#g&&this.#A()};#A=()=>{se.clear(),this.#g=null};#w=e=>{if(\"FORM\"!==e.target.tagName&&\"GET\"===e.detail.fetchOptions.method){const t=se.get(e.detail.url);t&&(e.detail.fetchRequest=t),se.clear()}};prepareRequest(e){const t=e.target;e.headers[\"X-Sec-Purpose\"]=\"prefetch\";const s=t.closest(\"turbo-frame\"),r=t.getAttribute(\"data-turbo-frame\")||s?.getAttribute(\"target\")||s?.id;r&&\"_top\"!==r&&(e.headers[\"Turbo-Frame\"]=r)}requestSucceededWithResponse(){}requestStarted(e){}requestErrored(e){}requestFinished(e){}requestPreventedHandlingResponse(e,t){}requestFailedWithResponse(e,t){}get#E(){return Number(S(\"turbo-prefetch-cache-time\"))||te}#y(e){return!!e.getAttribute(\"href\")&&(!Ye(e)&&(!Qe(e)&&(!Ze(e)&&(!et(e)&&!st(e)))))}}const Ye=e=>e.origin!==document.location.origin||![\"http:\",\"https:\"].includes(e.protocol)||e.hasAttribute(\"target\"),Qe=e=>e.pathname+e.search===document.location.pathname+document.location.search||e.href.startsWith(\"#\"),Ze=e=>{if(\"false\"===e.getAttribute(\"data-turbo-prefetch\"))return!0;if(\"false\"===e.getAttribute(\"data-turbo\"))return!0;const t=y(e,\"[data-turbo-prefetch]\");return!(!t||\"false\"!==t.getAttribute(\"data-turbo-prefetch\"))},et=e=>{const t=e.getAttribute(\"data-turbo-method\");return!(!t||\"get\"===t.toLowerCase())||(!!tt(e)||(!!e.hasAttribute(\"data-turbo-confirm\")||!!e.hasAttribute(\"data-turbo-stream\")))},tt=e=>e.hasAttribute(\"data-remote\")||e.hasAttribute(\"data-behavior\")||e.hasAttribute(\"data-confirm\")||e.hasAttribute(\"data-method\"),st=e=>i(\"turbo:before-prefetch\",{target:e,cancelable:!0}).defaultPrevented;class rt{constructor(e){this.delegate=e}proposeVisit(e,t={}){this.delegate.allowsVisitingLocationWithAction(e,t.action)&&this.delegate.visitProposedToLocation(e,t)}startVisit(e,t,s={}){this.stop(),this.currentVisit=new $e(this,k(e),t,{referrer:this.location,...s}),this.currentVisit.start()}submitForm(e,t){this.stop(),this.formSubmission=new ie(this,e,t,!0),this.formSubmission.start()}stop(){this.formSubmission&&(this.formSubmission.stop(),delete this.formSubmission),this.currentVisit&&(this.currentVisit.cancel(),delete this.currentVisit)}get adapter(){return this.delegate.adapter}get view(){return this.delegate.view}get rootLocation(){return this.view.snapshot.rootLocation}get history(){return this.delegate.history}formSubmissionStarted(e){\"function\"==typeof this.adapter.formSubmissionStarted&&this.adapter.formSubmissionStarted(e)}async formSubmissionSucceededWithResponse(e,t){if(e==this.formSubmission){const s=await t.responseHTML;if(s){const r=e.isSafe;r||this.view.clearSnapshotCache();const{statusCode:i,redirected:n}=t,o={action:this.#R(e,t),shouldCacheSnapshot:r,response:{statusCode:i,responseHTML:s,redirected:n}};this.proposeVisit(t.location,o)}}}async formSubmissionFailedWithResponse(e,t){const s=await t.responseHTML;if(s){const e=Pe.fromHTMLString(s);t.serverError?await this.view.renderError(e,this.currentVisit):await this.view.renderPage(e,!1,!0,this.currentVisit),\"preserve\"!==e.refreshScroll&&this.view.scrollToTop(),this.view.clearSnapshotCache()}}formSubmissionErrored(e,t){console.error(t)}formSubmissionFinished(e){\"function\"==typeof this.adapter.formSubmissionFinished&&this.adapter.formSubmissionFinished(e)}linkPrefetchingIsEnabledForLocation(e){return\"function\"!=typeof this.adapter.linkPrefetchingIsEnabledForLocation||this.adapter.linkPrefetchingIsEnabledForLocation(e)}visitStarted(e){this.delegate.visitStarted(e)}visitCompleted(e){this.delegate.visitCompleted(e),delete this.currentVisit}locationWithActionIsSamePage(e,t){return!1}get location(){return this.history.location}get restorationIdentifier(){return this.history.restorationIdentifier}#R(e,t){const{submitter:s,formElement:r}=e;return b(s,r)||this.#T(t)}#T(e){return e.redirected&&e.location.href===this.location?.href?\"replace\":\"advance\"}}const it=0,nt=1,ot=2,at=3;class ct{stage=it;started=!1;constructor(e){this.delegate=e}start(){this.started||(this.stage==it&&(this.stage=nt),document.addEventListener(\"readystatechange\",this.interpretReadyState,!1),addEventListener(\"pagehide\",this.pageWillUnload,!1),this.started=!0)}stop(){this.started&&(document.removeEventListener(\"readystatechange\",this.interpretReadyState,!1),removeEventListener(\"pagehide\",this.pageWillUnload,!1),this.started=!1)}interpretReadyState=()=>{const{readyState:e}=this;\"interactive\"==e?this.pageIsInteractive():\"complete\"==e&&this.pageIsComplete()};pageIsInteractive(){this.stage==nt&&(this.stage=ot,this.delegate.pageBecameInteractive())}pageIsComplete(){this.pageIsInteractive(),this.stage==ot&&(this.stage=at,this.delegate.pageLoaded())}pageWillUnload=()=>{this.delegate.pageWillUnload()};get readyState(){return document.readyState}}class lt{started=!1;constructor(e){this.delegate=e}start(){this.started||(addEventListener(\"scroll\",this.onScroll,!1),this.onScroll(),this.started=!0)}stop(){this.started&&(removeEventListener(\"scroll\",this.onScroll,!1),this.started=!1)}onScroll=()=>{this.updatePosition({x:window.pageXOffset,y:window.pageYOffset})};updatePosition(e){this.delegate.scrollPositionChanged(e)}}class ht{render({fragment:e}){pe.preservingPermanentElements(this,function(e){const t=ae(document.documentElement),s={};for(const r of t){const{id:t}=r;for(const i of e.querySelectorAll(\"turbo-stream\")){const e=oe(i.templateElement.content,t);e&&(s[t]=[r,e])}}return s}(e),(()=>{!async function(e,t){const s=`turbo-stream-autofocus-${d()}`,r=e.querySelectorAll(\"turbo-stream\"),i=function(e){for(const t of e){const e=A(t.templateElement.content);if(e)return e}return null}(r);let n=null;i&&(n=i.id?i.id:s,i.id=n);t(),await o();if((null==document.activeElement||document.activeElement==document.body)&&n){const e=document.getElementById(n);E(e)&&e.focus(),e&&e.id==s&&e.removeAttribute(\"id\")}}(e,(()=>{!async function(e){const[t,s]=await async function(e,t){const s=t();return e(),await a(),[s,t()]}(e,(()=>document.activeElement)),r=t&&t.id;if(r){const e=document.getElementById(r);E(e)&&e!=s&&e.focus()}}((()=>{document.documentElement.appendChild(e)}))}))}))}enteringBardo(e,t){t.replaceWith(e.cloneNode(!0))}leavingBardo(){}}class dt{sources=new Set;#L=!1;constructor(e){this.delegate=e}start(){this.#L||(this.#L=!0,addEventListener(\"turbo:before-fetch-response\",this.inspectFetchResponse,!1))}stop(){this.#L&&(this.#L=!1,removeEventListener(\"turbo:before-fetch-response\",this.inspectFetchResponse,!1))}connectStreamSource(e){this.streamSourceIsConnected(e)||(this.sources.add(e),e.addEventListener(\"message\",this.receiveMessageEvent,!1))}disconnectStreamSource(e){this.streamSourceIsConnected(e)&&(this.sources.delete(e),e.removeEventListener(\"message\",this.receiveMessageEvent,!1))}streamSourceIsConnected(e){return this.sources.has(e)}inspectFetchResponse=e=>{const t=function(e){const t=e.detail?.fetchResponse;if(t instanceof D)return t}(e);t&&function(e){const t=e.contentType??\"\";return t.startsWith(Q.contentType)}(t)&&(e.preventDefault(),this.receiveMessageResponse(t))};receiveMessageEvent=e=>{this.#L&&\"string\"==typeof e.data&&this.receiveMessageHTML(e.data)};async receiveMessageResponse(e){const t=await e.responseHTML;t&&this.receiveMessageHTML(t)}receiveMessageHTML(e){this.delegate.receivedMessageFromStream(Q.wrap(e))}}class ut extends fe{static renderElement(e,t){const{documentElement:s,body:r}=document;s.replaceChild(t,r)}async render(){this.replaceHeadAndBody(),this.activateScriptElements()}replaceHeadAndBody(){const{documentElement:e,head:t}=document;e.replaceChild(this.newHead,t),this.renderElement(this.currentElement,this.newElement)}activateScriptElements(){for(const e of this.scriptElements){const t=e.parentNode;if(t){const s=r(e);t.replaceChild(s,e)}}}get newHead(){return this.newSnapshot.headSnapshot.element}get scriptElements(){return document.documentElement.querySelectorAll(\"script\")}}class mt extends fe{static renderElement(e,t){document.body&&t instanceof HTMLBodyElement?document.body.replaceWith(t):document.documentElement.appendChild(t)}get shouldRender(){return this.newSnapshot.isVisitable&&this.trackedElementsAreIdentical}get reloadReason(){return this.newSnapshot.isVisitable?this.trackedElementsAreIdentical?void 0:{reason:\"tracked_element_mismatch\"}:{reason:\"turbo_visit_control_is_reload\"}}async prepareToRender(){this.#C(),await this.mergeHead()}async render(){this.willRender&&await this.replaceBody()}finishRendering(){super.finishRendering(),this.isPreview||this.focusFirstAutofocusableElement()}get currentHeadSnapshot(){return this.currentSnapshot.headSnapshot}get newHeadSnapshot(){return this.newSnapshot.headSnapshot}get newElement(){return this.newSnapshot.element}#C(){const{documentElement:e}=this.currentSnapshot,{dir:t,lang:s}=this.newSnapshot;s?e.setAttribute(\"lang\",s):e.removeAttribute(\"lang\"),t?e.setAttribute(\"dir\",t):e.removeAttribute(\"dir\")}async mergeHead(){const e=this.mergeProvisionalElements(),t=this.copyNewHeadStylesheetElements();this.copyNewHeadScriptElements(),await e,await t,this.willRender&&this.removeUnusedDynamicStylesheetElements()}async replaceBody(){await this.preservingPermanentElements((async()=>{this.activateNewBody(),await this.assignNewBody()}))}get trackedElementsAreIdentical(){return this.currentHeadSnapshot.trackedElementSignature==this.newHeadSnapshot.trackedElementSignature}async copyNewHeadStylesheetElements(){const e=[];for(const t of this.newHeadStylesheetElements)e.push(f(t)),document.head.appendChild(t);await Promise.all(e)}copyNewHeadScriptElements(){for(const e of this.newHeadScriptElements)document.head.appendChild(r(e))}removeUnusedDynamicStylesheetElements(){for(const e of this.unusedDynamicStylesheetElements)document.head.removeChild(e)}async mergeProvisionalElements(){const e=[...this.newHeadProvisionalElements];for(const t of this.currentHeadProvisionalElements)this.isCurrentElementInElementList(t,e)||document.head.removeChild(t);for(const t of e)document.head.appendChild(t)}isCurrentElementInElementList(e,t){for(const[s,r]of t.entries()){if(\"TITLE\"==e.tagName){if(\"TITLE\"!=r.tagName)continue;if(e.innerHTML==r.innerHTML)return t.splice(s,1),!0}if(r.isEqualNode(e))return t.splice(s,1),!0}return!1}removeCurrentHeadProvisionalElements(){for(const e of this.currentHeadProvisionalElements)document.head.removeChild(e)}copyNewHeadProvisionalElements(){for(const e of this.newHeadProvisionalElements)document.head.appendChild(e)}activateNewBody(){document.adoptNode(this.newElement),this.removeNoscriptElements(),this.activateNewBodyScriptElements()}removeNoscriptElements(){for(const e of this.newElement.querySelectorAll(\"noscript\"))e.remove()}activateNewBodyScriptElements(){for(const e of this.newBodyScriptElements){const t=r(e);e.replaceWith(t)}}async assignNewBody(){await this.renderElement(this.currentElement,this.newElement)}get unusedDynamicStylesheetElements(){return this.oldHeadStylesheetElements.filter((e=>\"dynamic\"===e.getAttribute(\"data-turbo-track\")))}get oldHeadStylesheetElements(){return this.currentHeadSnapshot.getStylesheetElementsNotInSnapshot(this.newHeadSnapshot)}get newHeadStylesheetElements(){return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)}get newHeadScriptElements(){return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot)}get currentHeadProvisionalElements(){return this.currentHeadSnapshot.provisionalElements}get newHeadProvisionalElements(){return this.newHeadSnapshot.provisionalElements}get newBodyScriptElements(){return this.newElement.querySelectorAll(\"script\")}}class pt extends mt{static renderElement(e,t){ve(e,t,{callbacks:{beforeNodeMorphed:(e,t)=>!(we(e,t)&&!ye(e))||(e.reload(),!1)}}),i(\"turbo:morph\",{detail:{currentElement:e,newElement:t}})}async preservingPermanentElements(e){return await e()}get renderMethod(){return\"morph\"}get shouldAutofocus(){return!1}}class ft extends ee{constructor(e){super(e,B)}get snapshots(){return this.entries}}class gt extends le{snapshotCache=new ft(10);lastRenderedLocation=new URL(location.href);forceReloaded=!1;shouldTransitionTo(e){return this.snapshot.prefersViewTransitions&&e.prefersViewTransitions}renderPage(e,t=!1,s=!0,r){const i=new(this.isPageRefresh(r)&&\"morph\"===(r?.refresh?.method||this.snapshot.refreshMethod)?pt:mt)(this.snapshot,e,t,s);return i.shouldRender?r?.changeHistory():this.forceReloaded=!0,this.render(i)}renderError(e,t){t?.changeHistory();const s=new ut(this.snapshot,e,!1);return this.render(s)}clearSnapshotCache(){this.snapshotCache.clear()}async cacheSnapshot(e=this.snapshot){if(e.isCacheable){this.delegate.viewWillCacheSnapshot();const{lastRenderedLocation:t}=this;await c();const s=e.clone();return this.snapshotCache.put(t,s),s}}getCachedSnapshotForLocation(e){return this.snapshotCache.get(e)}isPageRefresh(e){return!e||this.lastRenderedLocation.pathname===e.location.pathname&&\"replace\"===e.action}shouldPreserveScrollPosition(e){return this.isPageRefresh(e)&&\"preserve\"===(e?.refresh?.scroll||this.snapshot.refreshScroll)}get snapshot(){return Pe.fromElement(this.element)}}class bt{selector=\"a[data-turbo-preload]\";constructor(e,t){this.delegate=e,this.snapshotCache=t}start(){\"loading\"===document.readyState?document.addEventListener(\"DOMContentLoaded\",this.#P):this.preloadOnLoadLinksForView(document.body)}stop(){document.removeEventListener(\"DOMContentLoaded\",this.#P)}preloadOnLoadLinksForView(e){for(const t of e.querySelectorAll(this.selector))this.delegate.shouldPreloadLink(t)&&this.preloadURL(t)}async preloadURL(e){const t=new URL(e.href);if(this.snapshotCache.has(t))return;const s=new z(this,_.get,t,new URLSearchParams,e);await s.perform()}prepareRequest(e){e.headers[\"X-Sec-Purpose\"]=\"prefetch\"}async requestSucceededWithResponse(e,t){try{const s=await t.responseHTML,r=Pe.fromHTMLString(s);this.snapshotCache.put(e.url,r)}catch(e){}}requestStarted(e){}requestErrored(e){}requestFinished(e){}requestPreventedHandlingResponse(e,t){}requestFailedWithResponse(e,t){}#P=()=>{this.preloadOnLoadLinksForView(document.body)}}class vt{constructor(e){this.session=e}clear(){this.session.clearCache()}resetCacheControl(){this.#k(\"\")}exemptPageFromCache(){this.#k(\"no-cache\")}exemptPageFromPreview(){this.#k(\"no-preview\")}#k(e){!function(e,t){let s=v(e);s||(s=document.createElement(\"meta\"),s.setAttribute(\"name\",e),document.head.appendChild(s)),s.setAttribute(\"content\",t)}(\"turbo-cache-control\",e)}}function St(e){Object.defineProperties(e,wt)}const wt={absoluteURL:{get(){return this.toString()}}},yt=new class{navigator=new rt(this);history=new Je(this);view=new gt(this,document.documentElement);adapter=new ze(this);pageObserver=new ct(this);cacheObserver=new Ge;linkPrefetchObserver=new Xe(this,document);linkClickObserver=new ue(this,window);formSubmitObserver=new ce(this,document);scrollObserver=new lt(this);streamObserver=new dt(this);formLinkClickObserver=new me(this,document.documentElement);frameRedirector=new Ke(this,document.documentElement);streamMessageRenderer=new ht;cache=new vt(this);enabled=!0;started=!1;#M=150;constructor(e){this.recentRequests=e,this.preloader=new bt(this,this.view.snapshotCache),this.debouncedRefresh=this.refresh,this.pageRefreshDebouncePeriod=this.pageRefreshDebouncePeriod}start(){this.started||(this.pageObserver.start(),this.cacheObserver.start(),this.linkPrefetchObserver.start(),this.formLinkClickObserver.start(),this.linkClickObserver.start(),this.formSubmitObserver.start(),this.scrollObserver.start(),this.streamObserver.start(),this.frameRedirector.start(),this.history.start(),this.preloader.start(),this.started=!0,this.enabled=!0)}disable(){this.enabled=!1}stop(){this.started&&(this.pageObserver.stop(),this.cacheObserver.stop(),this.linkPrefetchObserver.stop(),this.formLinkClickObserver.stop(),this.linkClickObserver.stop(),this.formSubmitObserver.stop(),this.scrollObserver.stop(),this.streamObserver.stop(),this.frameRedirector.stop(),this.history.stop(),this.preloader.stop(),this.started=!1)}registerAdapter(e){this.adapter=e}visit(e,s={}){const r=s.frame?document.getElementById(s.frame):null;if(r instanceof t){const t=s.action||b(r);r.delegate.proposeVisitIfNavigatedWithAction(r,t),r.src=e.toString()}else this.navigator.proposeVisit(k(e),s)}refresh(e,t={}){t=\"string\"==typeof t?{requestId:t}:t;const{method:s,requestId:r,scroll:i}=t,n=r&&this.recentRequests.has(r),o=e===document.baseURI;n||this.navigator.currentVisit||!o||this.visit(e,{action:\"replace\",shouldCacheSnapshot:!1,refresh:{method:s,scroll:i}})}connectStreamSource(e){this.streamObserver.connectStreamSource(e)}disconnectStreamSource(e){this.streamObserver.disconnectStreamSource(e)}renderStreamMessage(e){this.streamMessageRenderer.render(Q.wrap(e))}clearCache(){this.view.clearSnapshotCache()}setProgressBarDelay(e){console.warn(\"Please replace `session.setProgressBarDelay(delay)` with `session.progressBarDelay = delay`. The function is deprecated and will be removed in a future version of Turbo.`\"),this.progressBarDelay=e}set progressBarDelay(e){P.drive.progressBarDelay=e}get progressBarDelay(){return P.drive.progressBarDelay}set drive(e){P.drive.enabled=e}get drive(){return P.drive.enabled}set formMode(e){P.forms.mode=e}get formMode(){return P.forms.mode}get location(){return this.history.location}get restorationIdentifier(){return this.history.restorationIdentifier}get pageRefreshDebouncePeriod(){return this.#M}set pageRefreshDebouncePeriod(e){this.refresh=function(e,t){let s=null;return(...r)=>{clearTimeout(s),s=setTimeout((()=>e.apply(this,r)),t)}}(this.debouncedRefresh.bind(this),e),this.#M=e}shouldPreloadLink(e){const s=e.hasAttribute(\"data-turbo-method\"),r=e.hasAttribute(\"data-turbo-stream\"),i=e.getAttribute(\"data-turbo-frame\"),n=\"_top\"==i?null:document.getElementById(i)||y(e,\"turbo-frame:not([disabled])\");if(s||r||n instanceof t)return!1;{const t=new URL(e.href);return this.elementIsNavigatable(e)&&q(t,this.snapshot.rootLocation)}}historyPoppedToLocationWithRestorationIdentifierAndDirection(e,t,s){this.enabled?this.navigator.startVisit(e,t,{action:\"restore\",historyChanged:!0,direction:s}):this.adapter.pageInvalidated({reason:\"turbo_disabled\"})}historyPoppedWithEmptyState(e){this.history.replace(e),this.view.lastRenderedLocation=e,this.view.cacheSnapshot()}scrollPositionChanged(e){this.history.updateRestorationData({scrollPosition:e})}willSubmitFormLinkToLocation(e,t){return this.elementIsNavigatable(e)&&q(t,this.snapshot.rootLocation)}submittedFormLinkToLocation(){}canPrefetchRequestToLocation(e,t){return this.elementIsNavigatable(e)&&q(t,this.snapshot.rootLocation)&&this.navigator.linkPrefetchingIsEnabledForLocation(t)}willFollowLinkToLocation(e,t,s){return this.elementIsNavigatable(e)&&q(t,this.snapshot.rootLocation)&&this.applicationAllowsFollowingLinkToLocation(e,t,s)}followedLinkToLocation(e,t){const s=this.getActionForLink(e),r=e.hasAttribute(\"data-turbo-stream\");this.visit(t.href,{action:s,acceptsStreamResponse:r})}allowsVisitingLocationWithAction(e,t){return this.applicationAllowsVisitingLocation(e)}visitProposedToLocation(e,t){St(e),this.adapter.visitProposedToLocation(e,t)}visitStarted(e){e.acceptsStreamResponse||(m(document.documentElement),this.view.markVisitDirection(e.direction)),St(e.location),this.notifyApplicationAfterVisitingLocation(e.location,e.action)}visitCompleted(e){this.view.unmarkVisitDirection(),p(document.documentElement),this.notifyApplicationAfterPageLoad(e.getTimingMetrics())}willSubmitForm(e,t){const s=F(e,t);return this.submissionIsNavigatable(e,t)&&q(k(s),this.snapshot.rootLocation)}formSubmitted(e,t){this.navigator.submitForm(e,t)}pageBecameInteractive(){this.view.lastRenderedLocation=this.location,this.notifyApplicationAfterPageLoad()}pageLoaded(){this.history.assumeControlOfScrollRestoration()}pageWillUnload(){this.history.relinquishControlOfScrollRestoration()}receivedMessageFromStream(e){this.renderStreamMessage(e)}viewWillCacheSnapshot(){this.notifyApplicationBeforeCachingSnapshot()}allowsImmediateRender({element:e},t){const s=this.notifyApplicationBeforeRender(e,t),{defaultPrevented:r,detail:{render:i}}=s;return this.view.renderer&&i&&(this.view.renderer.renderElement=i),!r}viewRenderedSnapshot(e,t,s){this.view.lastRenderedLocation=this.history.location,this.notifyApplicationAfterRender(s)}preloadOnLoadLinksForView(e){this.preloader.preloadOnLoadLinksForView(e)}viewInvalidated(e){this.adapter.pageInvalidated(e)}frameLoaded(e){this.notifyApplicationAfterFrameLoad(e)}frameRendered(e,t){this.notifyApplicationAfterFrameRender(e,t)}applicationAllowsFollowingLinkToLocation(e,t,s){return!this.notifyApplicationAfterClickingLinkToLocation(e,t,s).defaultPrevented}applicationAllowsVisitingLocation(e){return!this.notifyApplicationBeforeVisitingLocation(e).defaultPrevented}notifyApplicationAfterClickingLinkToLocation(e,t,s){return i(\"turbo:click\",{target:e,detail:{url:t.href,originalEvent:s},cancelable:!0})}notifyApplicationBeforeVisitingLocation(e){return i(\"turbo:before-visit\",{detail:{url:e.href},cancelable:!0})}notifyApplicationAfterVisitingLocation(e,t){return i(\"turbo:visit\",{detail:{url:e.href,action:t}})}notifyApplicationBeforeCachingSnapshot(){return i(\"turbo:before-cache\")}notifyApplicationBeforeRender(e,t){return i(\"turbo:before-render\",{detail:{newBody:e,...t},cancelable:!0})}notifyApplicationAfterRender(e){return i(\"turbo:render\",{detail:{renderMethod:e}})}notifyApplicationAfterPageLoad(e={}){return i(\"turbo:load\",{detail:{url:this.location.href,timing:e}})}notifyApplicationAfterFrameLoad(e){return i(\"turbo:frame-load\",{target:e})}notifyApplicationAfterFrameRender(e,t){return i(\"turbo:frame-render\",{detail:{fetchResponse:e},target:t,cancelable:!0})}submissionIsNavigatable(e,t){if(\"off\"==P.forms.mode)return!1;{const s=!t||this.elementIsNavigatable(t);return\"optin\"==P.forms.mode?s&&null!=e.closest('[data-turbo=\"true\"]'):s&&this.elementIsNavigatable(e)}}elementIsNavigatable(e){const t=y(e,\"[data-turbo]\"),s=y(e,\"turbo-frame\");return P.drive.enabled||s?!t||\"false\"!=t.getAttribute(\"data-turbo\"):!!t&&\"true\"==t.getAttribute(\"data-turbo\")}getActionForLink(e){return b(e)||\"advance\"}get snapshot(){return this.view.snapshot}}(V),{cache:Et,navigator:At}=yt;function Rt(){yt.start()}function Tt(e){yt.registerAdapter(e)}function Lt(e,t){yt.visit(e,t)}function Ct(e){yt.connectStreamSource(e)}function Pt(e){yt.disconnectStreamSource(e)}function kt(e){yt.renderStreamMessage(e)}function Mt(e){console.warn(\"Please replace `Turbo.setProgressBarDelay(delay)` with `Turbo.config.drive.progressBarDelay = delay`. The top-level function is deprecated and will be removed in a future version of Turbo.`\"),P.drive.progressBarDelay=e}function Ft(e){console.warn(\"Please replace `Turbo.setConfirmMethod(confirmMethod)` with `Turbo.config.forms.confirm = confirmMethod`. The top-level function is deprecated and will be removed in a future version of Turbo.`\"),P.forms.confirm=e}function It(e){console.warn(\"Please replace `Turbo.setFormMode(mode)` with `Turbo.config.forms.mode = mode`. The top-level function is deprecated and will be removed in a future version of Turbo.`\"),P.forms.mode=e}function qt(e,t){pt.renderElement(e,t)}function Ht(e,t){Ae.renderElement(e,t)}var Bt=Object.freeze({__proto__:null,PageRenderer:mt,PageSnapshot:Pe,FrameRenderer:ge,fetch:W,config:P,session:yt,cache:Et,navigator:At,start:Rt,registerAdapter:Tt,visit:Lt,connectStreamSource:Ct,disconnectStreamSource:Pt,renderStreamMessage:kt,setProgressBarDelay:Mt,setConfirmMethod:Ft,setFormMode:It,morphBodyElements:qt,morphTurboFrameElements:Ht,morphChildren:Se,morphElements:ve});class Nt extends Error{}function Ot(e,s){if(e){const r=e.getAttribute(\"src\");if(null!=r&&null!=s&&N(r,s))throw new Error(`Matching <turbo-frame id=\"${e.id}\"> element has a source URL which references itself`);if(e.ownerDocument!==document&&(e=document.importNode(e,!0)),e instanceof t)return e.connectedCallback(),e.disconnectedCallback(),e}}const Dt={after(){this.removeDuplicateTargetSiblings(),this.targetElements.forEach((e=>e.parentElement?.insertBefore(this.templateContent,e.nextSibling)))},append(){this.removeDuplicateTargetChildren(),this.targetElements.forEach((e=>e.append(this.templateContent)))},before(){this.removeDuplicateTargetSiblings(),this.targetElements.forEach((e=>e.parentElement?.insertBefore(this.templateContent,e)))},prepend(){this.removeDuplicateTargetChildren(),this.targetElements.forEach((e=>e.prepend(this.templateContent)))},remove(){this.targetElements.forEach((e=>e.remove()))},replace(){const e=this.getAttribute(\"method\");this.targetElements.forEach((t=>{\"morph\"===e?ve(t,this.templateContent):t.replaceWith(this.templateContent)}))},update(){const e=this.getAttribute(\"method\");this.targetElements.forEach((t=>{\"morph\"===e?Se(t,this.templateContent):(t.innerHTML=\"\",t.append(this.templateContent))}))},refresh(){const e=this.getAttribute(\"method\"),t=this.requestId,s=this.getAttribute(\"scroll\");yt.refresh(this.baseURI,{method:e,requestId:t,scroll:s})}};class xt extends HTMLElement{static async renderElement(e){await e.performAction()}async connectedCallback(){try{await this.render()}catch(e){console.error(e)}finally{this.disconnect()}}async render(){return this.renderPromise??=(async()=>{const e=this.beforeRenderEvent;this.dispatchEvent(e)&&(await o(),await e.detail.render(this))})()}disconnect(){try{this.remove()}catch{}}removeDuplicateTargetChildren(){this.duplicateChildren.forEach((e=>e.remove()))}get duplicateChildren(){const e=this.targetElements.flatMap((e=>[...e.children])).filter((e=>!!e.getAttribute(\"id\"))),t=[...this.templateContent?.children||[]].filter((e=>!!e.getAttribute(\"id\"))).map((e=>e.getAttribute(\"id\")));return e.filter((e=>t.includes(e.getAttribute(\"id\"))))}removeDuplicateTargetSiblings(){this.duplicateSiblings.forEach((e=>e.remove()))}get duplicateSiblings(){const e=this.targetElements.flatMap((e=>[...e.parentElement.children])).filter((e=>!!e.id)),t=[...this.templateContent?.children||[]].filter((e=>!!e.id)).map((e=>e.id));return e.filter((e=>t.includes(e.id)))}get performAction(){if(this.action){const e=Dt[this.action];if(e)return e;this.#F(\"unknown action\")}this.#F(\"action attribute is missing\")}get targetElements(){return this.target?this.targetElementsById:this.targets?this.targetElementsByQuery:void this.#F(\"target or targets attribute is missing\")}get templateContent(){return this.templateElement.content.cloneNode(!0)}get templateElement(){if(null===this.firstElementChild){const e=this.ownerDocument.createElement(\"template\");return this.appendChild(e),e}if(this.firstElementChild instanceof HTMLTemplateElement)return this.firstElementChild;this.#F(\"first child element must be a <template> element\")}get action(){return this.getAttribute(\"action\")}get target(){return this.getAttribute(\"target\")}get targets(){return this.getAttribute(\"targets\")}get requestId(){return this.getAttribute(\"request-id\")}#F(e){throw new Error(`${this.description}: ${e}`)}get description(){return(this.outerHTML.match(/<[^>]+>/)??[])[0]??\"<turbo-stream>\"}get beforeRenderEvent(){return new CustomEvent(\"turbo:before-stream-render\",{bubbles:!0,cancelable:!0,detail:{newStream:this,render:xt.renderElement}})}get targetElementsById(){const e=this.ownerDocument?.getElementById(this.target);return null!==e?[e]:[]}get targetElementsByQuery(){const e=this.ownerDocument?.querySelectorAll(this.targets);return 0!==e.length?Array.prototype.slice.call(e):[]}}class Vt extends HTMLElement{streamSource=null;connectedCallback(){this.streamSource=this.src.match(/^ws{1,2}:/)?new WebSocket(this.src):new EventSource(this.src),Ct(this.streamSource)}disconnectedCallback(){this.streamSource&&(this.streamSource.close(),Pt(this.streamSource))}get src(){return this.getAttribute(\"src\")||\"\"}}t.delegateConstructor=class{fetchResponseLoaded=e=>Promise.resolve();#I=null;#q=()=>{};#H=!1;#B=!1;#N=new Set;#O=!1;action=null;constructor(e){this.element=e,this.view=new he(this,this.element),this.appearanceObserver=new Y(this,this.element),this.formLinkClickObserver=new me(this,this.element),this.linkInterceptor=new de(this,this.element),this.restorationIdentifier=d(),this.formSubmitObserver=new ce(this,this.element)}connect(){this.#H||(this.#H=!0,this.loadingStyle==e.lazy?this.appearanceObserver.start():this.#D(),this.formLinkClickObserver.start(),this.linkInterceptor.start(),this.formSubmitObserver.start())}disconnect(){this.#H&&(this.#H=!1,this.appearanceObserver.stop(),this.formLinkClickObserver.stop(),this.linkInterceptor.stop(),this.formSubmitObserver.stop(),this.element.hasAttribute(\"recurse\")||this.#I?.cancel())}disabledChanged(){this.disabled?this.#I?.cancel():this.loadingStyle==e.eager&&this.#D()}sourceURLChanged(){this.#x(\"src\")||(this.sourceURL||this.#I?.cancel(),this.element.isConnected&&(this.complete=!1),(this.loadingStyle==e.eager||this.#B)&&this.#D())}sourceURLReloaded(){const{refresh:e,src:t}=this.element;return this.#O=t&&\"morph\"===e,this.element.removeAttribute(\"complete\"),this.element.src=null,this.element.src=t,this.element.loaded}loadingStyleChanged(){this.loadingStyle==e.lazy?this.appearanceObserver.start():(this.appearanceObserver.stop(),this.#D())}async#D(){this.enabled&&this.isActive&&!this.complete&&this.sourceURL&&(this.element.loaded=this.#V(k(this.sourceURL)),this.appearanceObserver.stop(),await this.element.loaded,this.#B=!0)}async loadResponse(e){(e.redirected||e.succeeded&&e.isHTML)&&(this.sourceURL=e.response.url);try{const t=await e.responseHTML;if(t){const s=l(t);Pe.fromDocument(s).isVisitable?await this.#W(e,s):await this.#U(e)}}finally{this.#O=!1,this.fetchResponseLoaded=()=>Promise.resolve()}}elementAppearedInViewport(e){this.proposeVisitIfNavigatedWithAction(e,b(e)),this.#D()}willSubmitFormLinkToLocation(e){return this.#_(e)}submittedFormLinkToLocation(e,t,s){const r=this.#p(e);r&&s.setAttribute(\"data-turbo-frame\",r.id)}shouldInterceptLinkClick(e,t,s){return this.#_(e)}linkClickIntercepted(e,t){this.#$(e,t)}willSubmitForm(e,t){return e.closest(\"turbo-frame\")==this.element&&this.#_(e,t)}formSubmitted(e,t){this.formSubmission&&this.formSubmission.stop(),this.formSubmission=new ie(this,e,t);const{fetchRequest:s}=this.formSubmission,r=this.#p(e,t);this.prepareRequest(s,r),this.formSubmission.start()}prepareRequest(e,t=this){e.headers[\"Turbo-Frame\"]=t.id,this.currentNavigationElement?.hasAttribute(\"data-turbo-stream\")&&e.acceptResponseType(Q.contentType)}requestStarted(e){m(this.element)}requestPreventedHandlingResponse(e,t){this.#q()}async requestSucceededWithResponse(e,t){await this.loadResponse(t),this.#q()}async requestFailedWithResponse(e,t){await this.loadResponse(t),this.#q()}requestErrored(e,t){console.error(t),this.#q()}requestFinished(e){p(this.element)}formSubmissionStarted({formElement:e}){m(e,this.#p(e))}formSubmissionSucceededWithResponse(e,t){const s=this.#p(e.formElement,e.submitter);s.delegate.proposeVisitIfNavigatedWithAction(s,b(e.submitter,e.formElement,s)),s.delegate.loadResponse(t),e.isSafe||yt.clearCache()}formSubmissionFailedWithResponse(e,t){this.element.delegate.loadResponse(t),yt.clearCache()}formSubmissionErrored(e,t){console.error(t)}formSubmissionFinished({formElement:e}){p(e,this.#p(e))}allowsImmediateRender({element:e},t){const s=i(\"turbo:before-frame-render\",{target:this.element,detail:{newFrame:e,...t},cancelable:!0}),{defaultPrevented:r,detail:{render:n}}=s;return this.view.renderer&&n&&(this.view.renderer.renderElement=n),!r}viewRenderedSnapshot(e,t,s){}preloadOnLoadLinksForView(e){yt.preloadOnLoadLinksForView(e)}viewInvalidated(){}willRenderFrame(e,t){this.previousFrameElement=e.cloneNode(!0)}visitCachedSnapshot=({element:e})=>{const t=e.querySelector(\"#\"+this.element.id);t&&this.previousFrameElement&&t.replaceChildren(...this.previousFrameElement.children),delete this.previousFrameElement};async#W(e,t){const s=await this.extractForeignFrameElement(t.body),r=this.#O?Ae:ge;if(s){const t=new ne(s),i=new r(this,this.view.snapshot,t,!1,!1);this.view.renderPromise&&await this.view.renderPromise,this.changeHistory(),await this.view.render(i),this.complete=!0,yt.frameRendered(e,this.element),yt.frameLoaded(this.element),await this.fetchResponseLoaded(e)}else this.#j(e)&&this.#z(e)}async#V(e){const t=new z(this,_.get,e,new URLSearchParams,this.element);return this.#I?.cancel(),this.#I=t,new Promise((e=>{this.#q=()=>{this.#q=()=>{},this.#I=null,e()},t.perform()}))}#$(e,t,s){const r=this.#p(e,s);r.delegate.proposeVisitIfNavigatedWithAction(r,b(s,e,r)),this.#G(e,(()=>{r.src=t}))}proposeVisitIfNavigatedWithAction(e,t=null){if(this.action=t,this.action){const t=Pe.fromElement(e).clone(),{visitCachedSnapshot:s}=e.delegate;e.delegate.fetchResponseLoaded=async r=>{if(e.src){const{statusCode:i,redirected:n}=r,o={response:{statusCode:i,redirected:n,responseHTML:await r.responseHTML},visitCachedSnapshot:s,willRender:!1,updateHistory:!1,restorationIdentifier:this.restorationIdentifier,snapshot:t};this.action&&(o.action=this.action),yt.visit(e.src,o)}}}}changeHistory(){if(this.action){const e=g(this.action);yt.history.update(e,k(this.element.src||\"\"),this.restorationIdentifier)}}async#U(e){console.warn(`The response (${e.statusCode}) from <turbo-frame id=\"${this.element.id}\"> is performing a full page visit due to turbo-visit-control.`),await this.#K(e.response)}#j(e){this.element.setAttribute(\"complete\",\"\");const t=e.response;return!i(\"turbo:frame-missing\",{target:this.element,detail:{response:t,visit:async(e,t)=>{e instanceof Response?this.#K(e):yt.visit(e,t)}},cancelable:!0}).defaultPrevented}#z(e){this.view.missing(),this.#J(e)}#J(e){const t=`The response (${e.statusCode}) did not contain the expected <turbo-frame id=\"${this.element.id}\"> and will be ignored. To perform a full page visit instead, set turbo-visit-control to reload.`;throw new Nt(t)}async#K(e){const t=new D(e),s=await t.responseHTML,{location:r,redirected:i,statusCode:n}=t;return yt.visit(r,{response:{redirected:i,statusCode:n,responseHTML:s}})}#p(e,s){const r=u(\"data-turbo-frame\",s,e)||this.element.getAttribute(\"target\"),i=this.#X(r);return i instanceof t?i:this.element}async extractForeignFrameElement(e){let s;const r=CSS.escape(this.id);try{if(s=Ot(e.querySelector(`turbo-frame#${r}`),this.sourceURL),s)return s;if(s=Ot(e.querySelector(`turbo-frame[src][recurse~=${r}]`),this.sourceURL),s)return await s.loaded,await this.extractForeignFrameElement(s)}catch(e){return console.error(e),new t}return null}#Y(e,t){return q(k(F(e,t)),this.rootLocation)}#_(e,t){const s=u(\"data-turbo-frame\",t,e)||this.element.getAttribute(\"target\");if(e instanceof HTMLFormElement&&!this.#Y(e,t))return!1;if(!this.enabled||\"_top\"==s)return!1;if(s){const e=this.#X(s);if(e)return!e.disabled;if(\"_parent\"==s)return!1}return!!yt.elementIsNavigatable(e)&&!(t&&!yt.elementIsNavigatable(t))}get id(){return this.element.id}get disabled(){return this.element.disabled}get enabled(){return!this.disabled}get sourceURL(){if(this.element.src)return this.element.src}set sourceURL(e){this.#Q(\"src\",(()=>{this.element.src=e??null}))}get loadingStyle(){return this.element.loading}get isLoading(){return void 0!==this.formSubmission||void 0!==this.#q()}get complete(){return this.element.hasAttribute(\"complete\")}set complete(e){e?this.element.setAttribute(\"complete\",\"\"):this.element.removeAttribute(\"complete\")}get isActive(){return this.element.isActive&&this.#H}get rootLocation(){const e=this.element.ownerDocument.querySelector('meta[name=\"turbo-root\"]');return k(e?.content??\"/\")}#x(e){return this.#N.has(e)}#Q(e,t){this.#N.add(e),t(),this.#N.delete(e)}#G(e,t){this.currentNavigationElement=e,t(),delete this.currentNavigationElement}#X(e){if(null!=e){const s=\"_parent\"===e?this.element.parentElement.closest(\"turbo-frame\"):document.getElementById(e);if(s instanceof t)return s}}},void 0===customElements.get(\"turbo-frame\")&&customElements.define(\"turbo-frame\",t),void 0===customElements.get(\"turbo-stream\")&&customElements.define(\"turbo-stream\",xt),void 0===customElements.get(\"turbo-stream-source\")&&customElements.define(\"turbo-stream-source\",Vt),(()=>{const e=document.currentScript;if(!e)return;if(e.hasAttribute(\"data-turbo-suppress-warning\"))return;let t=e.parentElement;for(;t;){if(t==document.body)return console.warn(h`\n        You are loading Turbo from a <script> element inside the <body> element. This is probably not what you meant to do!\n\n        Load your application’s JavaScript bundle inside the <head> element instead. <script> elements in <body> are evaluated with each page change.\n\n        For more information, see: https://turbo.hotwired.dev/handbook/building#working-with-script-elements\n\n        ——\n        Suppress this warning by adding a \"data-turbo-suppress-warning\" attribute to: %s\n      `,e.outerHTML);t=t.parentElement}})(),window.Turbo={...Bt,StreamActions:Dt},Rt();var Wt=Object.freeze({__proto__:null,FetchEnctype:j,FetchMethod:_,FetchRequest:z,FetchResponse:D,FrameElement:t,FrameLoadingStyle:e,FrameRenderer:ge,PageRenderer:mt,PageSnapshot:Pe,StreamActions:Dt,StreamElement:xt,StreamSourceElement:Vt,cache:Et,config:P,connectStreamSource:Ct,disconnectStreamSource:Pt,fetch:W,fetchEnctypeFromString:$,fetchMethodFromString:U,isSafe:G,morphBodyElements:qt,morphChildren:Se,morphElements:ve,morphTurboFrameElements:Ht,navigator:At,registerAdapter:Tt,renderStreamMessage:kt,session:yt,setConfirmMethod:Ft,setFormMode:It,setProgressBarDelay:Mt,start:Rt,visit:Lt});let Ut;async function _t(){return Ut||$t(jt().then($t))}function $t(e){return Ut=e}async function jt(){const{createConsumer:e}=await Promise.resolve().then((function(){return fs}));return e()}async function zt(e,t){const{subscriptions:s}=await _t();return s.create(e,t)}var Gt=Object.freeze({__proto__:null,getConsumer:_t,setConsumer:$t,createConsumer:jt,subscribeTo:zt});function Kt(e){return e&&\"object\"==typeof e?e instanceof Date||e instanceof RegExp?e:Array.isArray(e)?e.map(Kt):Object.keys(e).reduce((function(t,s){return t[s[0].toLowerCase()+s.slice(1).replace(/([A-Z]+)/g,(function(e,t){return\"_\"+t.toLowerCase()}))]=Kt(e[s]),t}),{}):e}class Jt extends HTMLElement{static observedAttributes=[\"channel\",\"signed-stream-name\"];async connectedCallback(){Ct(this),this.subscription=await zt(this.channel,{received:this.dispatchMessageEvent.bind(this),connected:this.subscriptionConnected.bind(this),disconnected:this.subscriptionDisconnected.bind(this)})}disconnectedCallback(){Pt(this),this.subscription&&this.subscription.unsubscribe(),this.subscriptionDisconnected()}attributeChangedCallback(){this.subscription&&(this.disconnectedCallback(),this.connectedCallback())}dispatchMessageEvent(e){const t=new MessageEvent(\"message\",{data:e});return this.dispatchEvent(t)}subscriptionConnected(){this.setAttribute(\"connected\",\"\")}subscriptionDisconnected(){this.removeAttribute(\"connected\")}get channel(){return{channel:this.getAttribute(\"channel\"),signed_stream_name:this.getAttribute(\"signed-stream-name\"),...Kt({...this.dataset})}}}void 0===customElements.get(\"turbo-cable-stream-source\")&&customElements.define(\"turbo-cable-stream-source\",Jt),window.Turbo=Wt,addEventListener(\"turbo:before-fetch-request\",(function(e){if(e.target instanceof HTMLFormElement){const{target:t,detail:{fetchOptions:s}}=e;t.addEventListener(\"turbo:submit-start\",(({detail:{formSubmission:{submitter:e}}})=>{const r=function(e){return e instanceof FormData||e instanceof URLSearchParams}(s.body)?s.body:new URLSearchParams,i=function(e,t,s){const r=function(e){return e instanceof HTMLButtonElement||e instanceof HTMLInputElement?\"_method\"===e.name?e.value:e.hasAttribute(\"formmethod\")?e.formMethod:null:null}(e),i=t.get(\"_method\"),n=s.getAttribute(\"method\")||\"get\";return\"string\"==typeof r?r:\"string\"==typeof i?i:n}(e,r,t);/get/i.test(i)||(/post/i.test(i)?r.delete(\"_method\"):r.set(\"_method\",i),s.method=\"post\")}),{once:!0})}}));var Xt={logger:\"undefined\"!=typeof console?console:void 0,WebSocket:\"undefined\"!=typeof WebSocket?WebSocket:void 0},Yt={log(...e){this.enabled&&(e.push(Date.now()),Xt.logger.log(\"[ActionCable]\",...e))}};const Qt=()=>(new Date).getTime(),Zt=e=>(Qt()-e)/1e3;class es{constructor(e){this.visibilityDidChange=this.visibilityDidChange.bind(this),this.connection=e,this.reconnectAttempts=0}start(){this.isRunning()||(this.startedAt=Qt(),delete this.stoppedAt,this.startPolling(),addEventListener(\"visibilitychange\",this.visibilityDidChange),Yt.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`))}stop(){this.isRunning()&&(this.stoppedAt=Qt(),this.stopPolling(),removeEventListener(\"visibilitychange\",this.visibilityDidChange),Yt.log(\"ConnectionMonitor stopped\"))}isRunning(){return this.startedAt&&!this.stoppedAt}recordMessage(){this.pingedAt=Qt()}recordConnect(){this.reconnectAttempts=0,delete this.disconnectedAt,Yt.log(\"ConnectionMonitor recorded connect\")}recordDisconnect(){this.disconnectedAt=Qt(),Yt.log(\"ConnectionMonitor recorded disconnect\")}startPolling(){this.stopPolling(),this.poll()}stopPolling(){clearTimeout(this.pollTimeout)}poll(){this.pollTimeout=setTimeout((()=>{this.reconnectIfStale(),this.poll()}),this.getPollInterval())}getPollInterval(){const{staleThreshold:e,reconnectionBackoffRate:t}=this.constructor;return 1e3*e*Math.pow(1+t,Math.min(this.reconnectAttempts,10))*(1+(0===this.reconnectAttempts?1:t)*Math.random())}reconnectIfStale(){this.connectionIsStale()&&(Yt.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${Zt(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`),this.reconnectAttempts++,this.disconnectedRecently()?Yt.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${Zt(this.disconnectedAt)} s`):(Yt.log(\"ConnectionMonitor reopening\"),this.connection.reopen()))}get refreshedAt(){return this.pingedAt?this.pingedAt:this.startedAt}connectionIsStale(){return Zt(this.refreshedAt)>this.constructor.staleThreshold}disconnectedRecently(){return this.disconnectedAt&&Zt(this.disconnectedAt)<this.constructor.staleThreshold}visibilityDidChange(){\"visible\"===document.visibilityState&&setTimeout((()=>{!this.connectionIsStale()&&this.connection.isOpen()||(Yt.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`),this.connection.reopen())}),200)}}es.staleThreshold=6,es.reconnectionBackoffRate=.15;var ts=es,ss={message_types:{welcome:\"welcome\",disconnect:\"disconnect\",ping:\"ping\",confirmation:\"confirm_subscription\",rejection:\"reject_subscription\"},disconnect_reasons:{unauthorized:\"unauthorized\",invalid_request:\"invalid_request\",server_restart:\"server_restart\",remote:\"remote\"},default_mount_path:\"/cable\",protocols:[\"actioncable-v1-json\",\"actioncable-unsupported\"]};const{message_types:rs,protocols:is}=ss,ns=is.slice(0,is.length-1),os=[].indexOf;class as{constructor(e){this.open=this.open.bind(this),this.consumer=e,this.subscriptions=this.consumer.subscriptions,this.monitor=new ts(this),this.disconnected=!0}send(e){return!!this.isOpen()&&(this.webSocket.send(JSON.stringify(e)),!0)}open(){if(this.isActive())return Yt.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`),!1;{const e=[...is,...this.consumer.subprotocols||[]];return Yt.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${e}`),this.webSocket&&this.uninstallEventHandlers(),this.webSocket=new Xt.WebSocket(this.consumer.url,e),this.installEventHandlers(),this.monitor.start(),!0}}close({allowReconnect:e}={allowReconnect:!0}){if(e||this.monitor.stop(),this.isOpen())return this.webSocket.close()}reopen(){if(Yt.log(`Reopening WebSocket, current state is ${this.getState()}`),!this.isActive())return this.open();try{return this.close()}catch(e){Yt.log(\"Failed to reopen WebSocket\",e)}finally{Yt.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`),setTimeout(this.open,this.constructor.reopenDelay)}}getProtocol(){if(this.webSocket)return this.webSocket.protocol}isOpen(){return this.isState(\"open\")}isActive(){return this.isState(\"open\",\"connecting\")}triedToReconnect(){return this.monitor.reconnectAttempts>0}isProtocolSupported(){return os.call(ns,this.getProtocol())>=0}isState(...e){return os.call(e,this.getState())>=0}getState(){if(this.webSocket)for(let e in Xt.WebSocket)if(Xt.WebSocket[e]===this.webSocket.readyState)return e.toLowerCase();return null}installEventHandlers(){for(let e in this.events){const t=this.events[e].bind(this);this.webSocket[`on${e}`]=t}}uninstallEventHandlers(){for(let e in this.events)this.webSocket[`on${e}`]=function(){}}}as.reopenDelay=500,as.prototype.events={message(e){if(!this.isProtocolSupported())return;const{identifier:t,message:s,reason:r,reconnect:i,type:n}=JSON.parse(e.data);switch(this.monitor.recordMessage(),n){case rs.welcome:return this.triedToReconnect()&&(this.reconnectAttempted=!0),this.monitor.recordConnect(),this.subscriptions.reload();case rs.disconnect:return Yt.log(`Disconnecting. Reason: ${r}`),this.close({allowReconnect:i});case rs.ping:return null;case rs.confirmation:return this.subscriptions.confirmSubscription(t),this.reconnectAttempted?(this.reconnectAttempted=!1,this.subscriptions.notify(t,\"connected\",{reconnected:!0})):this.subscriptions.notify(t,\"connected\",{reconnected:!1});case rs.rejection:return this.subscriptions.reject(t);default:return this.subscriptions.notify(t,\"received\",s)}},open(){if(Yt.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`),this.disconnected=!1,!this.isProtocolSupported())return Yt.log(\"Protocol is unsupported. Stopping monitor and disconnecting.\"),this.close({allowReconnect:!1})},close(e){if(Yt.log(\"WebSocket onclose event\"),!this.disconnected)return this.disconnected=!0,this.monitor.recordDisconnect(),this.subscriptions.notifyAll(\"disconnected\",{willAttemptReconnect:this.monitor.isRunning()})},error(){Yt.log(\"WebSocket onerror event\")}};var cs=as;class ls{constructor(e,t={},s){this.consumer=e,this.identifier=JSON.stringify(t),function(e,t){if(null!=t)for(let s in t){const r=t[s];e[s]=r}}(this,s)}perform(e,t={}){return t.action=e,this.send(t)}send(e){return this.consumer.send({command:\"message\",identifier:this.identifier,data:JSON.stringify(e)})}unsubscribe(){return this.consumer.subscriptions.remove(this)}}var hs=class{constructor(e){this.subscriptions=e,this.pendingSubscriptions=[]}guarantee(e){-1==this.pendingSubscriptions.indexOf(e)?(Yt.log(`SubscriptionGuarantor guaranteeing ${e.identifier}`),this.pendingSubscriptions.push(e)):Yt.log(`SubscriptionGuarantor already guaranteeing ${e.identifier}`),this.startGuaranteeing()}forget(e){Yt.log(`SubscriptionGuarantor forgetting ${e.identifier}`),this.pendingSubscriptions=this.pendingSubscriptions.filter((t=>t!==e))}startGuaranteeing(){this.stopGuaranteeing(),this.retrySubscribing()}stopGuaranteeing(){clearTimeout(this.retryTimeout)}retrySubscribing(){this.retryTimeout=setTimeout((()=>{this.subscriptions&&\"function\"==typeof this.subscriptions.subscribe&&this.pendingSubscriptions.map((e=>{Yt.log(`SubscriptionGuarantor resubscribing ${e.identifier}`),this.subscriptions.subscribe(e)}))}),500)}};class ds{constructor(e){this.consumer=e,this.guarantor=new hs(this),this.subscriptions=[]}create(e,t){const s=\"object\"==typeof e?e:{channel:e},r=new ls(this.consumer,s,t);return this.add(r)}add(e){return this.subscriptions.push(e),this.consumer.ensureActiveConnection(),this.notify(e,\"initialized\"),this.subscribe(e),e}remove(e){return this.forget(e),this.findAll(e.identifier).length||this.sendCommand(e,\"unsubscribe\"),e}reject(e){return this.findAll(e).map((e=>(this.forget(e),this.notify(e,\"rejected\"),e)))}forget(e){return this.guarantor.forget(e),this.subscriptions=this.subscriptions.filter((t=>t!==e)),e}findAll(e){return this.subscriptions.filter((t=>t.identifier===e))}reload(){return this.subscriptions.map((e=>this.subscribe(e)))}notifyAll(e,...t){return this.subscriptions.map((s=>this.notify(s,e,...t)))}notify(e,t,...s){let r;return r=\"string\"==typeof e?this.findAll(e):[e],r.map((e=>\"function\"==typeof e[t]?e[t](...s):void 0))}subscribe(e){this.sendCommand(e,\"subscribe\")&&this.guarantor.guarantee(e)}confirmSubscription(e){Yt.log(`Subscription confirmed ${e}`),this.findAll(e).map((e=>this.guarantor.forget(e)))}sendCommand(e,t){const{identifier:s}=e;return this.consumer.send({command:t,identifier:s})}}class us{constructor(e){this._url=e,this.subscriptions=new ds(this),this.connection=new cs(this),this.subprotocols=[]}get url(){return ms(this._url)}send(e){return this.connection.send(e)}connect(){return this.connection.open()}disconnect(){return this.connection.close({allowReconnect:!1})}ensureActiveConnection(){if(!this.connection.isActive())return this.connection.open()}addSubProtocol(e){this.subprotocols=[...this.subprotocols,e]}}function ms(e){if(\"function\"==typeof e&&(e=e()),e&&!/^wss?:/i.test(e)){const t=document.createElement(\"a\");return t.href=e,t.href=t.href,t.protocol=t.protocol.replace(\"http\",\"ws\"),t.href}return e}function ps(e){const t=document.head.querySelector(`meta[name='action-cable-${e}']`);if(t)return t.getAttribute(\"content\")}var fs=Object.freeze({__proto__:null,Connection:cs,ConnectionMonitor:ts,Consumer:us,INTERNAL:ss,Subscription:ls,Subscriptions:ds,SubscriptionGuarantor:hs,adapters:Xt,createWebSocketURL:ms,logger:Yt,createConsumer:function(e=ps(\"url\")||ss.default_mount_path){return new us(e)},getConfig:ps});export{Wt as Turbo,Gt as cable};\n//# sourceMappingURL=turbo.min.js.map\n"}]}