{"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## Setup\nSeed the database — the Rails-traditional `storage/development.sqlite3`, from the bundled `db/seed.sql` (needs the `sqlite3` CLI):\n```sh\nsqlite3 storage/development.sqlite3 < db/seed.sql\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 24+ 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, generated from the app's own db/seeds.rb.\n-- The archive is text-only, so no binary database ships; this file\n-- populates a fresh one for any target: `sqlite3 <db> < db/seed.sql`.\n-- INSERTs name their columns explicitly so the file stays valid whatever\n-- order a target emits its schema columns in.\n-- created_at/updated_at are synthesized (seeds.rb sets neither, and the\n-- columns are NOT NULL): a fixed base instant plus one microsecond per\n-- row, in creation order, so ordering by them is deterministic.\nCREATE TABLE IF NOT EXISTS articles (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  title TEXT,\n  body TEXT,\n  created_at TEXT NOT NULL,\n  updated_at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS comments (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  article_id INTEGER NOT NULL,\n  commenter TEXT,\n  body TEXT,\n  created_at TEXT NOT NULL,\n  updated_at TEXT NOT NULL\n);\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-01-01 00:00:00.000001','2026-01-01 00:00:00.000001');\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-01-01 00:00:00.000002','2026-01-01 00:00:00.000002');\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-01-01 00:00:00.000003','2026-01-01 00:00:00.000003');\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-01-01 00:00:00.000004','2026-01-01 00:00:00.000004');\nINSERT INTO comments (id, article_id, commenter, body, created_at, updated_at) VALUES (3,2,'Carol','This pattern really helps keep code organized!','2026-01-01 00:00:00.000005','2026-01-01 00:00:00.000005');\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-01-01 00:00:00.000006','2026-01-01 00:00:00.000006');\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: runs on every target. All targets now back flash with a\n// per-session `rh_flash` cookie (HttpOnly, cleared when empty), so the\n// sweep is isolated per browser and no longer races the comment specs'\n// `redirect_to … notice:` under `fullyParallel` — no target sets E2E_SKIP\n// for flash. (History: crystal/typescript once shared one global in-memory\n// flash slot and were skipped here; that was removed when they gained\n// cookie-backed per-session flash, matching go/rust/kotlin/swift/etc.)\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  \"description\": \"Playwright end-to-end smoke tests for this archive — see ../README.md\",\n  \"devDependencies\": {\n    \"@playwright/test\": \"1.60.0\"\n  },\n  \"name\": \"app-e2e\",\n  \"private\": true,\n  \"scripts\": {\n    \"test\": \"playwright test\"\n  },\n  \"type\": \"module\"\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 storage/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, 'storage/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\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)\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, 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\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\nFORGERY_SLOT = [true]\nREDIRECT_LINE_BREAKS = { \"\\r\" => \"\", \"\\n\" => \"\", \"\\0\" => \"\", \"\\t\" => \"\" }\nREDIRECT_LINE_BREAK_PATTERN = /[\\r\\n\\0\\t]/\nSTATUS_CODES = {continue: 100_i64, switching_protocols: 101_i64, processing: 102_i64, early_hints: 103_i64, ok: 200_i64, created: 201_i64, accepted: 202_i64, non_authoritative_information: 203_i64, no_content: 204_i64, reset_content: 205_i64, partial_content: 206_i64, multi_status: 207_i64, already_reported: 208_i64, im_used: 226_i64, multiple_choices: 300_i64, moved_permanently: 301_i64, found: 302_i64, see_other: 303_i64, not_modified: 304_i64, use_proxy: 305_i64, temporary_redirect: 307_i64, permanent_redirect: 308_i64, bad_request: 400_i64, unauthorized: 401_i64, payment_required: 402_i64, forbidden: 403_i64, not_found: 404_i64, method_not_allowed: 405_i64, not_acceptable: 406_i64, proxy_authentication_required: 407_i64, request_timeout: 408_i64, conflict: 409_i64, gone: 410_i64, length_required: 411_i64, precondition_failed: 412_i64, payload_too_large: 413_i64, content_too_large: 413_i64, uri_too_long: 414_i64, unsupported_media_type: 415_i64, range_not_satisfiable: 416_i64, expectation_failed: 417_i64, misdirected_request: 421_i64, unprocessable_entity: 422_i64, unprocessable_content: 422_i64, locked: 423_i64, failed_dependency: 424_i64, too_early: 425_i64, upgrade_required: 426_i64, precondition_required: 428_i64, too_many_requests: 429_i64, request_header_fields_too_large: 431_i64, unavailable_for_legal_reasons: 451_i64, internal_server_error: 500_i64, not_implemented: 501_i64, bad_gateway: 502_i64, service_unavailable: 503_i64, gateway_timeout: 504_i64, http_version_not_supported: 505_i64, variant_also_negotiates: 506_i64, insufficient_storage: 507_i64, loop_detected: 508_i64, bandwidth_limit_exceeded: 509_i64, not_extended: 510_i64, network_authentication_required: 511_i64}\n\nmodule ActionController\n  class HeaderStore\n    @keys : Array(String)\n    @vals : Array(String)\n\n    def initialize : Nil\n      @keys = [] of String\n      @vals = [] of String\n    end\n\n    def [](key : String) : String?\n      i = 0_i64\n      while i < @keys.size\n        return @vals[i] if @keys[i] == key\n        i += 1_i64\n      end\n      nil\n    end\n\n    def []=(key : String, value : String?) : Nil\n      if ::ActionController.header_key_ok?(key) && ::ActionController.header_value_ok?(value)\n        i = 0_i64\n        found = false\n        while i < @keys.size\n          if @keys[i] == key\n            @vals[i] = value\n            found = true\n          end\n          i += 1_i64\n        end\n        if found\n          nil\n        else\n          @keys << key\n          @vals << value\n        end\n      end\n    end\n\n    def size\n      @keys.size\n    end\n\n    def key_at(i : Int64) : String\n      @keys[i].to_s\n    end\n\n    def val_at(i : Int64) : String\n      @vals[i].to_s\n    end\n  end\nend\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 accepts_any_format : Bool\n    property path_parameters : Hash(String, String)\n    property status : Int64\n    property body : String\n    property location : String?\n    property content_type : String\n    property cache_control_max_age : Int64\n    property cache_control_public : Bool\n    @headers : ActionController::HeaderStore\n    @performed : Bool\n\n    def self.allow_forgery_protection : Bool\n      ::ActionController.forgery_flag.not_nil!\n    end\n\n    def self.allow_forgery_protection=(value : Bool) : Nil\n      ::ActionController.set_forgery_flag(value)\n    end\n\n    def initialize : Nil\n      @params = {} of String => Roundhouse::ParamValue\n      @path_parameters = {} of String => String\n      @session = ::ActionDispatch::Session.new\n      @flash = ::ActionDispatch::Flash.new\n      @status = 200_i64\n      @body = \"\"\n      @location = nil\n      @request_method = \"\"\n      @request_path = \"\"\n      @request_format = :html\n      @accepts_any_format = false\n      @content_type = \"text/html; charset=utf-8\"\n      @headers = ::ActionController::HeaderStore.new\n      @performed = false\n      @cache_control_max_age = 0_i64\n      @cache_control_public = false\n    end\n\n    def performed? : Bool\n      @performed\n    end\n\n    def reset_session : ActionDispatch::Session\n      @session = ::ActionDispatch::Session.new\n      @session.not_nil!\n    end\n\n    def assign_http_session(value : ActionDispatch::Session) : ActionDispatch::Session\n      @session = value\n      @session.not_nil!\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      @performed = true\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 = ::ActionController.sanitize_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      loc = ::ActionController.sanitize_location(path)\n      loc = same_host_location(loc)\n      @location = loc\n      @status = resolve_status(status)\n      @performed = true\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, location : String? = nil) : Nil\n      if location.nil?\n        nil\n      else\n        @location = ::ActionController.sanitize_location(location)\n      end\n      @status = resolve_status(status)\n      @body = \"\"\n      @performed = true\n      if content_type.nil?\n        nil\n      else\n        @content_type = content_type\n      end\n      nil\n    end\n\n    def response\n      self\n    end\n\n    def fresh_when(subject)\n    end\n\n    def stale?(etag = nil)\n      true\n    end\n\n    def expires_in(seconds : Int64, public : Bool = false, stale_while_revalidate : Int64 = 0_i64) : Nil\n      @cache_control_max_age = seconds\n      @cache_control_public = public\n      nil\n    end\n\n    def headers : ActionController::HeaderStore\n      @headers\n    end\n\n    def send_data(data : String, type_ : String = \"application/octet-stream\", disposition : String = \"attachment\") : Nil\n      @body = data\n      @content_type = type_\n      @performed = true\n      disp = if disposition.to_s == \"inline\"\n        \"inline\"\n      else\n        \"attachment\"\n      end\n      @headers[\"Content-Disposition\"] = disp\n      nil\n    end\n\n    def verify_authenticity_token : Nil\n      if verified_request?.not_nil!\n        nil\n      else\n        render(\"<h1>422 Unprocessable Content</h1>\", status: :unprocessable_content)\n      end\n      nil\n    end\n\n    def verified_request? : Bool\n      if ::ActionController.forgery_flag.not_nil!\n        nil\n      else\n        return true\n      end\n      verb = @request_method.to_s\n      return true if verb == \"\" || verb == \"GET\" || verb == \"HEAD\"\n      expected = session[:_csrf_token].to_s\n      return false if expected.empty?\n      given = params[\"authenticity_token\"].to_s\n      return true if given.size > 0_i64 && given == expected\n      header = csrf_header_token.not_nil!\n      header.size > 0_i64 && header == expected\n    end\n\n    def csrf_header_token : String\n      \"\"\n    end\n\n    def request_for_csrf\n      nil\n    end\n\n    def same_host_location(loc : String) : String\n      host = ::ActionController.location_host(loc)\n      return loc if host.empty?\n      req_host = request_host_for_redirect.not_nil!\n      raise ArgumentError.new(\"Unsafe redirect to \\\"\" + loc + \"\\\", pass allow_other_host: true to redirect anyway.\") if req_host.empty? || host != req_host.downcase\n      loc\n    end\n\n    def request_host_for_redirect : String\n      \"\"\n    end\n\n    def resolve_status(s : Symbol) : Int64\n      if STATUS_CODES.has_key?(s)\n        nil\n      else\n        raise \"Invalid HTTP status: #{s}\"\n      end\n      STATUS_CODES.fetch(s, 200_i64)\n    end\n  end\nend\n\nmodule ActionController\n  def self.forgery_flag : Bool\n    FORGERY_SLOT[0_i64] == true\n  end\n\n  def self.set_forgery_flag(value : Bool) : Nil\n    FORGERY_SLOT[0_i64] = value\n  end\n\n  def self.header_key_ok?(k : String?) : Bool\n    return false if k.nil?\n    n = k.size\n    return false if n == 0_i64\n    i = 0_i64\n    while i < n\n      c = k[i, 1_i64].to_s\n      return false if c == \"\\\"\" || c == \":\" || c == \" \" || header_control?(c)\n      i += 1_i64\n    end\n    true\n  end\n\n  def self.header_value_ok?(v : String?) : Bool\n    return false if v.nil?\n    n = v.size\n    i = 0_i64\n    while i < n\n      c = v[i, 1_i64].to_s\n      return false if c != \"\\t\" && header_control?(c)\n      i += 1_i64\n    end\n    true\n  end\n\n  def self.header_control?(c : String) : Bool\n    c == \"\\0\" || c == \"\\r\" || c == \"\\n\" || c == \"\\u{1}\" || c == \"\\u{2}\" || c == \"\\u{3}\" || c == \"\\u{4}\" || c == \"\\u{5}\" || c == \"\\u{6}\" || c == \"\\u{7}\" || c == \"\\u{8}\" || c == \"\\t\" || c == \"\\u{b}\" || c == \"\\u{c}\" || c == \"\\u{e}\" || c == \"\\u{f}\" || c == \"\\u{10}\" || c == \"\\u{11}\" || c == \"\\u{12}\" || c == \"\\u{13}\" || c == \"\\u{14}\" || c == \"\\u{15}\" || c == \"\\u{16}\" || c == \"\\u{17}\" || c == \"\\u{18}\" || c == \"\\u{19}\" || c == \"\\u{1a}\" || c == \"\\u{1b}\" || c == \"\\u{1c}\" || c == \"\\u{1d}\" || c == \"\\u{1e}\" || c == \"\\u{1f}\" || c == \"\\u{7f}\"\n  end\n\n  def self.sanitize_location(path : String) : String\n    s = path.to_s\n    s = s.gsub(REDIRECT_LINE_BREAK_PATTERN, REDIRECT_LINE_BREAKS) if s.includes?(\"\\r\") || s.includes?(\"\\n\") || s.includes?(\"\\0\") || s.includes?(\"\\t\")\n    s = s.tr(\"\\\\\", \"/\")\n    keep = true\n    while keep && s.size > 0_i64\n      c = s[0_i64, 1_i64].to_s\n      if c == \" \" || header_control?(c)\n        s = s[1_i64, s.size].to_s\n      else\n        keep = false\n      end\n    end\n    keep = true\n    while keep && s.size > 0_i64\n      c = s[s.size - 1_i64, 1_i64].to_s\n      if c == \" \" || header_control?(c)\n        s = s[0_i64, s.size - 1_i64].to_s\n      else\n        keep = false\n      end\n    end\n    s\n  end\n\n  def self.location_host(url : String) : String\n    s = url.to_s\n    return \"\" if s.empty?\n    rest = s\n    if s.starts_with?(\"//\")\n      rest = s[2_i64, s.size].to_s\n      return \".\" if rest.empty? || rest.starts_with?(\"/\")\n    else\n      at = find_substr(s, \"://\")\n      return \"\" if at < 0_i64\n      rest = s[at + 3_i64, s.size].to_s\n    end\n    slash = find_substr(rest, \"/\")\n    hostport = if slash < 0_i64\n      rest\n    else\n      rest[0_i64, slash].to_s\n    end\n    q = find_substr(hostport, \"?\")\n    if q < 0_i64\n      nil\n    else\n      hostport = hostport[0_i64, q].to_s\n    end\n    hash = find_substr(hostport, \"#\")\n    if hash < 0_i64\n      nil\n    else\n      hostport = hostport[0_i64, hash].to_s\n    end\n    user = find_last(hostport, \"@\")\n    if user < 0_i64\n      nil\n    else\n      hostport = hostport[user + 1_i64, hostport.size].to_s\n    end\n    hostport.downcase\n  end\n\n  def self.find_substr(hay : String, needle : String) : Int64\n    n = needle.size\n    i = 0_i64\n    last = hay.size - n\n    while i <= last\n      return i if hay[i, n].to_s == needle\n      i += 1_i64\n    end\n    -1_i64\n  end\n\n  def self.find_last(hay : String, needle : String) : Int64\n    n = needle.size\n    i = hay.size - n\n    while i >= 0_i64\n      return i if hay[i, n].to_s == needle\n      i -= 1_i64\n    end\n    -1_i64\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\nEMPTY_ATTRS = {} of String => String\nHYDRATE_ATTRS = {} of String => String\n\nmodule ActiveRecord\n  class Base\n    property id : Int64\n    @errors : Array(String)\n    @id_previously_changed : Bool\n    @persisted : Bool\n    @destroyed : Bool\n\n    def to_param : String\n      raise NotImplementedError.new(\"to_param: subclasses must override\")\n    end\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      @id_previously_changed = false\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._table_sql : String\n      table_name.not_nil!\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.primary_key : String\n      \"id\"\n    end\n\n    def self._cast_primary_key(_id : Int64 | String) : Int64 | String | Nil\n      raise NotImplementedError.new(\"finder casting requires the Relation runtime\")\n    end\n\n    def self._string_primary_key : Bool\n      false\n    end\n\n    def self.schema_time_columns : Array(Symbol)\n      [] of String\n    end\n\n    def self.schema_date_columns : Array(Symbol)\n      [] of String\n    end\n\n    def self.instantiate(_row)\n      raise NotImplementedError.new(\"#{name}.instantiate must be overridden\")\n    end\n\n    def self.preload_associations(_records, _specs)\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 self._adapter_last\n      records = _adapter_all\n      if records.empty?\n        nil\n      else\n        records[-1_i64]\n      end\n    end\n\n    def self._columns_sql : String\n      \"#{table_name.not_nil!}.*\"\n    end\n\n    def self._hydrate_all(_sql : String)\n      raise NotImplementedError.new(\"#{name}._hydrate_all must be overridden\")\n    end\n\n    def _adapter_insert : Int64\n      raise NotImplementedError.new(\"_adapter_insert: subclasses must override\")\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_any? : Bool\n      size.not_nil! > 0_i64\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 _note_unloaded(_row)\n    end\n\n    def has_attribute?(name : Symbol) : Bool\n      cols = self.class.schema_columns\n      cols.includes?(name)\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 dom_record_key : String\n      raise NotImplementedError.new(\"dom_record_key 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.find_by!(conditions)\n      result = find_by(conditions)\n      raise RecordNotFound.new(\"Couldn't find #{name}\") if result.nil?\n      result\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.none? : Bool\n      !(_adapter_any?.not_nil!)\n    end\n\n    def self.any? : Bool\n      _adapter_any?.not_nil!\n    end\n\n    def self.delete_all : Nil\n      ::ActiveRecord.adapter.delete_all(table_name.not_nil!)\n      nil\n    end\n\n    def self.destroy_all\n      records = _adapter_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      _adapter_last\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      save_after_validation.not_nil!\n    end\n\n    def save_after_validation : Bool\n      before_save\n      was_new = new_record?.not_nil!\n      if was_new\n        before_create\n        fill_timestamps(true)\n        self.id = _adapter_insert.not_nil!\n        @persisted = true\n        __track_saved_changes(was_new)\n        after_create\n        after_create_commit\n      else\n        before_update\n        fill_timestamps(false)\n        _adapter_update\n        __track_saved_changes(was_new)\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 __track_saved_changes(was_new : Bool) : Nil\n      @id_previously_changed = was_new\n      nil\n    end\n\n    def saved_changes\n      {} of String => String\n    end\n\n    def _note_hydrated : Nil\n    end\n\n    def saved_change_to_attribute?(name : Symbol) : Bool\n      changes = saved_changes\n      changes.has_key?(name)\n    end\n\n    def saved_change_to_attribute(name)\n      changes = saved_changes\n      changes[name]\n    end\n\n    def attribute_previously_was(name)\n      changes = saved_changes\n      changes[name]\n    end\n\n    def changes_to_save\n      {} of String => String\n    end\n\n    def attribute_changed?(name : String) : Bool\n      changes = changes_to_save\n      changes.has_key?(name)\n    end\n\n    def attribute_was(name)\n      changes = changes_to_save\n      changes[name]\n    end\n\n    def id_previously_changed? : Bool\n      @id_previously_changed\n    end\n\n    def previously_new_record? : Bool\n      @id_previously_changed\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 touch : Bool\n      fill_timestamps(false)\n      _adapter_update\n      after_touch\n      true\n    end\n\n    def destroy!\n      destroy\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 after_initialize : Nil\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 = Roundhouse::DateTime.db_now.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?\n\n  def self.enum_label(value : Int64, labels : Array(String), values : Array(Int64)) : String?\n    found = -1_i64\n    i = 0_i64\n    while i < values.size\n      found = i if found == -1_i64 && values[i] == value\n      i += 1_i64\n    end\n    if found == -1_i64\n      nil\n    else\n      labels[found]\n    end\n  end\n\n  def self.enum_int(text : String, labels : Array(String), values : Array(Int64), texts : Array(String), attr : String) : Int64\n    result = 0_i64\n    found = text == \"\"\n    i = 0_i64\n    while i < labels.size\n      if labels[i] == text || texts[i] == text\n        result = values[i]\n        found = true\n      end\n      i += 1_i64\n    end\n    if found\n      nil\n    else\n      raise ArgumentError.new(\"'\" + text + \"' is not a valid \" + attr)\n    end\n    result\n  end\n\n  def self.enum_int_or_nil(text : String, labels : Array(String), values : Array(Int64), texts : Array(String), attr : String) : Int64?\n    if text == \"\"\n      nil\n    else\n      enum_int(text, labels, values, texts, attr)\n    end\n  end\n\n  def self.enum_str(text : String, labels : Array(String), values : Array(String), attr : String) : String\n    result = text + \"\"\n    found = text == \"\"\n    i = 0_i64\n    while i < labels.size\n      if labels[i] == text || values[i] == text\n        result = values[i]\n        found = true\n      end\n      i += 1_i64\n    end\n    if found\n      nil\n    else\n      raise ArgumentError.new(\"'\" + text + \"' is not a valid \" + attr)\n    end\n    result\n  end\n\n  def self.enum_str_or_nil(text : String, labels : Array(String), values : Array(String), attr : String) : String?\n    if text == \"\"\n      nil\n    else\n      enum_str(text, labels, values, attr)\n    end\n  end\n\n  def self.enum_label_str(value : String, labels : Array(String), values : Array(String)) : String?\n    found = -1_i64\n    i = 0_i64\n    while i < values.size\n      found = i if found == -1_i64 && values[i] == value\n      i += 1_i64\n    end\n    if found == -1_i64\n      nil\n    else\n      labels[found]\n    end\n  end\n\n  def self.lower_bound(sorted : Array(Int64), value : Int64) : Int64\n    n = sorted.size\n    step = 1_i64\n    while step + step <= n\n      step = step + step\n    end\n    lo = 0_i64\n    half = 0_i64\n    while step > 0_i64\n      lo = lo + step if lo + step <= n && sorted[lo + step - 1_i64].to_i < value\n      half = 0_i64\n      if step > 1_i64\n        half = 1_i64\n        while half + half < step\n          half = half + half\n        end\n      end\n      step = half\n    end\n    lo\n  end\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 \"./datetime\"\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 \"./params\"\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\"\nrequire \"./views/pwa/manifest_json\"\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  # `attributes` is the EXTRA attribute text the turbo-stream element\n  # carries — ` maintain_scroll=\"true\"` — rendered by the broadcast\n  # lowering, where the literal hash the app wrote can be read. A String\n  # rather than a hash so this twin needs no markup renderer of its own.\n\n  def self.append(*, stream : String, target : String, html : String, attributes : String = \"\") : Nil\n    record(\"append\", stream, target, html, attributes)\n  end\n\n  def self.prepend(*, stream : String, target : String, html : String, attributes : String = \"\") : Nil\n    record(\"prepend\", stream, target, html, attributes)\n  end\n\n  def self.replace(*, stream : String, target : String, html : String, attributes : String = \"\") : Nil\n    record(\"replace\", stream, target, html, attributes)\n  end\n  def self.update(*, stream : String, target : String, html : String, attributes : String = \"\") : Nil\n    record(\"update\", stream, target, html, attributes)\n  end\n\n  def self.remove(*, stream : String, target : String, attributes : String = \"\") : Nil\n    record(\"remove\", stream, target, \"\", attributes)\n  end\n\n  private def self.record(action : String, stream : String, target : String, html : String, attributes : 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, attributes))\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 = \"\", attributes : String = \"\") : String\n    turbo_stream_fragment(action, target, html, attributes)\n  end\n\n  # Compose the `<turbo-stream>` element for a `turbo_stream.<action>`\n  # call in a `.turbo_stream.erb` template. The ONE shape the view\n  # lowerer emits on every target; `render_fragment` delegates so the\n  # markup has a single owner.\n  #\n  # `attributes` carries its own leading space and is written BEFORE\n  # `action`/`target`, where turbo-rails' `tag.turbo_stream(template,\n  # **attributes, action:, target:)` puts it. Optional, so the\n  # three-argument call the view lowerer emits is unchanged.\n  def self.turbo_stream_fragment(action : String, target : String, html : String, attributes : String = \"\") : String\n    if action == \"remove\"\n      %(<turbo-stream#{attributes} action=\"remove\" target=\"#{target}\"></turbo-stream>)\n    else\n      %(<turbo-stream#{attributes} 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, title, body, created_at, 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, commenter, body, created_at, updated_at FROM comments WHERE article_id IN (\" + Roundhouse::Db.escape_int_list(__comments_ids) + \") ORDER BY article_id, id\")\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    __comments_fks = __comments_loaded.map { |r| r.article_id.not_nil! }\n    results.each { |a| __comments_group = [] of Comment\n    __comments_j = ::ActiveRecord.lower_bound(__comments_fks, a.id.not_nil!)\n    __comments_stop = ::ActiveRecord.lower_bound(__comments_fks, a.id.not_nil! + 1_i64)\n    while __comments_j < __comments_stop\n      __comments_group << __comments_loaded[__comments_j]\n      __comments_j = __comments_j + 1_i64\n    end\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_from_article_params(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/datetime.cr","content":"# Roundhouse Crystal datetime runtime — the native-`Time` seam for\n# temporal (Date/DateTime/Time) columns.\n#\n# Storage stays portable ISO-8601 TEXT: a temporal column hydrates into a\n# `String` ivar (`column_text`), exactly like every other target. The\n# model's synthesized reader parses that text into a native `Time` via\n# `Roundhouse::DateTime.parse` (see `src/emit/crystal/expr.rs`, which maps\n# the `ActiveSupport.parse_db_time` intrinsic here). JSON serialization\n# then formats a `Time` back to Rails' canonical `...Z` millisecond form\n# via the `JsonBuilder.encode_datetime(Time?)` overload below.\n\nmodule Roundhouse\n  module DateTime\n    # Parse a stored ISO-8601 value into a native UTC `Time`. Nil-safe:\n    # nil / empty → nil. Handles the two forms roundhouse ever stores:\n    #\n    #   * DB-dump / seed form — \"2026-05-15 21:14:56.300213\" (space\n    #     separator, zone-less, microsecond precision, implicitly UTC).\n    #   * RFC3339 form — \"2026-05-15T21:14:56Z\" (what `fill_timestamps`\n    #     writes via `Time.utc.to_rfc3339`, and API-supplied values).\n    #\n    # A value that parses under neither returns nil rather than raising —\n    # a malformed stored timestamp shouldn't take down a read path.\n    def self.parse(s : String?) : Time?\n      return nil if s.nil?\n      str = s\n      return nil if str.empty?\n      if str.size > 10 && str[10] == ' '\n        fmt = str.includes?('.') ? \"%F %H:%M:%S.%N\" : \"%F %H:%M:%S\"\n        begin\n          return Time.parse(str, fmt, Time::Location::UTC)\n        rescue\n          return nil\n        end\n      end\n      begin\n        Time.parse_rfc3339(str)\n      rescue\n        nil\n      end\n    end\n\n    # Write-side sibling of `parse`: current UTC time in Rails' exact\n    # storage form — \"YYYY-MM-DD HH:MM:SS.ffffff\", space separator,\n    # zero-padded 6-digit fractional seconds, no zone marker (implicitly\n    # UTC, byte-matching what Rails' sqlite3 adapter writes).\n    # `fill_timestamps` stamps with this (the `ActiveSupport.db_now`\n    # intrinsic maps here) so a column's TEXT values stay homogeneous —\n    # and lexicographically ordered — when a roundhouse-emitted app\n    # shares a database with a real Rails app.\n    def self.db_now : String\n      Time.utc.to_s(\"%F %H:%M:%S.%6N\")\n    end\n\n    # Write-side normalize sibling of `db_now` — the\n    # `ActiveSupport.format_db_time` intrinsic behind the synthesized\n    # public `<col>=` temporal writer. nil → nil; a native `Time`\n    # formats to the same storage text `db_now` produces.\n    def self.format_db_time(value : Time?) : String?\n      return nil if value.nil?\n      value.to_utc.to_s(\"%F %H:%M:%S.%6N\")\n    end\n  end\nend\n\nmodule JsonBuilder\n  # `Time` overload of `encode_datetime`. The transpiled `String?` version\n  # (json_builder.cr, from the shared runtime) handles pre-formatted text;\n  # this one formats a native `Time` — what a temporal column's reader\n  # yields — to Rails' canonical JSON shape: UTC, millisecond precision,\n  # `Z` suffix (e.g. \"2026-05-15T21:14:56.300Z\"). The compare harness\n  # canonicalizes Rails' microsecond precision down to milliseconds, so\n  # this matches byte-for-byte.\n  def self.encode_datetime(t : Time?) : String\n    return \"null\" if t.nil?\n    \"\\\"#{t.to_utc.to_rfc3339(fraction_digits: 3)}\\\"\"\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    # Nullable-column reads. A column the schema declares nullable\n    # holds NULL until something sets it, and NULL is not the type's\n    # zero: \"\" in a nullable UNIQUE column collides row-to-row, and 0\n    # in a nullable fk makes `where(fk: nil)` match nothing. The\n    # lowerer emits these for those columns; the readers above stay as\n    # they are for NOT NULL columns.\n    def self.column_int_opt(stmt_id : Int64, i : Int64) : Int64?\n      entry = @@statements[stmt_id]\n      row = entry.current.not_nil!\n      row[i].nil? ? nil : column_int(stmt_id, i)\n    end\n\n    def self.column_float_opt(stmt_id : Int64, i : Int64) : Float64?\n      entry = @@statements[stmt_id]\n      row = entry.current.not_nil!\n      v = row[i]\n      case v\n      when Nil     then nil\n      when Float64 then v\n      when Float32 then v.to_f64\n      when Int64   then v.to_f64\n      when Int32   then v.to_f64\n      when String  then v.to_f64? || 0.0\n      else              0.0\n      end\n    end\n\n    def self.column_text_opt(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 nil\n      when String then v\n      else             v.to_s\n      end\n    end\n\n    def self.column_bool_opt(stmt_id : Int64, i : Int64) : Bool?\n      v = column_int_opt(stmt_id, i)\n      v.nil? ? nil : v != 0\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    # Nullable-column writes: nil renders the SQL keyword NULL rather\n    # than `''` / `0`, so a nullable column round-trips as nil.\n    def self.escape_string_opt(s : String?) : String\n      s.nil? ? \"NULL\" : escape_string(s)\n    end\n\n    def self.escape_int_opt(n : Int?) : String\n      n.nil? ? \"NULL\" : escape_int(n)\n    end\n\n    def self.escape_float_opt(f : Float?) : String\n      f.nil? ? \"NULL\" : f.to_s\n    end\n\n    def self.escape_bool_opt(b : Bool?) : String\n      b.nil? ? \"NULL\" : (b ? \"1\" : \"0\")\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    abstract def delete_all(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\n    def delete_all(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 ValueTooLong < Exception\n    def initialize(message : String = \"ActiveRecord::ValueTooLong\") : Nil\n      super(message)\n    end\n  end\nend\n\nmodule ActiveRecord\n  class RecordNotUnique < Exception\n    def initialize(message : String = \"ActiveRecord::RecordNotUnique\") : 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_after_validation.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_after_validation.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_after_validation.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_after_validation.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 now : ActionDispatch::FlashNow\n      ::ActionDispatch::FlashNow.new(self)\n    end\n\n    def mark_shown(key : String | Symbol) : Nil\n      k = key.to_s\n      @notice_was = @notice if k == \"notice\"\n      @alert_was = @alert if k == \"alert\"\n      nil\n    end\n\n    def [](key : String | Symbol) : String?\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 : String | Symbol, value : String?) : String?\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 : String | Symbol, default : String? = nil) : String?\n      v = self[key]\n      return v if !(v.nil?)\n      default\n    end\n\n    def key?(key : String | Symbol) : Bool\n      v = self[key]\n      !(v.nil?)\n    end\n\n    def has_key?(key : String | Symbol) : Bool\n      key?(key)\n    end\n\n    def include?(key : String | Symbol) : Bool\n      key?(key)\n    end\n\n    def delete(key : String | Symbol) : String?\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\nmodule ActionDispatch\n  class FlashNow\n    @flash : ActionDispatch::Flash\n\n    def initialize(flash : ActionDispatch::Flash) : Nil\n      @flash = flash\n    end\n\n    def []=(key : String | Symbol, value : String?) : String?\n      @flash[key] = value\n      @flash.not_nil!.mark_shown(key)\n      value\n    end\n\n    def [](key : String | Symbol) : String?\n      @flash[key]\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\n\n  def self.pluralize_formatted(count : String, word : String) : String\n    singular_lines = count.split(\"\\n\").map do |line| line == \"1\" || line.starts_with?(\"1.\") && line.size > 2_i64 && line.split(\"0\").join(\"\") == \"1.\" end\n    if singular_lines.includes?(true)\n      \"#{count} #{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\", \"<\" => \"\\\\u003c\", \">\" => \"\\\\u003e\", \"&\" => \"\\\\u0026\" }\nESCAPE_PATTERN = /[\\\\\"\\n\\r\\t\\x08\\f<>&]/\nJSON_HTML_ESCAPE_PATTERN = /[<>&]/\n\nmodule JsonBuilder\n  def self.encode_string(s : String) : String\n    s.gsub(ESCAPE_PATTERN, ESCAPES)\n  end\n\n  def self.escape_html_entities(json : String) : String\n    json.gsub(JSON_HTML_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_string_array(values : Array(String)) : String\n    \"[\" + values.map { |v| \"\\\"#{encode_string(v)}\\\"\" }.join(\",\") + \"]\"\n  end\n\n  def self.encode_enum(labels : Array(String), value : Int64) : String\n    return \"null\" if value < 0_i64 || value >= labels.size\n    \"\\\"#{encode_string(labels[value])}\\\"\"\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: RouteTable.table,\n  root_route: RouteTable.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 title : String?\n  property body : String?\n  property created_at_raw : String?\n  property updated_at_raw : String?\n  @comments_cache : Array(Comment) = [] of Comment\n  @comments_loaded : Bool = false\n\n  def id? : Bool\n    !(@id.nil?) && @id.not_nil! != 0_i64\n  end\n\n  def title? : Bool\n    !(@title.nil?) && (@title.not_nil!).as?(String).not_nil! != \"\"\n  end\n\n  def title_previously_changed? : Bool\n    !(saved_changes[\"title\"].nil?)\n  end\n\n  def saved_change_to_title? : Bool\n    !(saved_changes[\"title\"].nil?)\n  end\n\n  def title_previously_was\n    attribute_previously_was(\"title\")\n  end\n\n  def body? : Bool\n    !(@body.nil?) && (@body.not_nil!).as?(String).not_nil! != \"\"\n  end\n\n  def body_previously_changed? : Bool\n    !(saved_changes[\"body\"].nil?)\n  end\n\n  def saved_change_to_body? : Bool\n    !(saved_changes[\"body\"].nil?)\n  end\n\n  def body_previously_was\n    attribute_previously_was(\"body\")\n  end\n\n  def created_at : Time?\n    Roundhouse::DateTime.parse(@created_at_raw)\n  end\n\n  def created_at=(value : Time) : Nil\n    self.created_at_raw = Roundhouse::DateTime.format_db_time(value)\n  end\n\n  def created_at? : Bool\n    !(@created_at_raw.nil?) && @created_at_raw.not_nil! != \"\"\n  end\n\n  def created_at_previously_changed? : Bool\n    !(saved_changes[\"created_at\"].nil?)\n  end\n\n  def saved_change_to_created_at? : Bool\n    !(saved_changes[\"created_at\"].nil?)\n  end\n\n  def created_at_previously_was\n    attribute_previously_was(\"created_at\")\n  end\n\n  def updated_at : Time?\n    Roundhouse::DateTime.parse(@updated_at_raw)\n  end\n\n  def updated_at=(value : Time) : Nil\n    self.updated_at_raw = Roundhouse::DateTime.format_db_time(value)\n  end\n\n  def updated_at? : Bool\n    !(@updated_at_raw.nil?) && @updated_at_raw.not_nil! != \"\"\n  end\n\n  def updated_at_previously_changed? : Bool\n    !(saved_changes[\"updated_at\"].nil?)\n  end\n\n  def saved_change_to_updated_at? : Bool\n    !(saved_changes[\"updated_at\"].nil?)\n  end\n\n  def updated_at_previously_was\n    attribute_previously_was(\"updated_at\")\n  end\n\n  def self.table_name : String\n    \"articles\"\n  end\n\n  def self._table_sql : String\n    \"articles\"\n  end\n\n  def self.schema_columns : Array(Symbol)\n    [:id, :title, :body, :created_at, :updated_at]\n  end\n\n  def self.schema_time_columns : Array(Symbol)\n    [:created_at, :updated_at]\n  end\n\n  def self.schema_date_columns : Array(Symbol)\n    [] of Symbol\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.title = (row.title).as?(String?)\n    instance.body = (row.body).as?(String?)\n    instance.created_at_raw = (row.created_at.not_nil!).as?(String).not_nil!\n    instance.updated_at_raw = (row.updated_at.not_nil!).as?(String).not_nil!\n    instance._note_hydrated\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.title = Roundhouse::Db.column_text_opt(stmt, 1_i64)\n    instance.body = Roundhouse::Db.column_text_opt(stmt, 2_i64)\n    instance.created_at_raw = Roundhouse::Db.column_text(stmt, 3_i64)\n    instance.updated_at_raw = Roundhouse::Db.column_text(stmt, 4_i64)\n    instance.mark_persisted!\n    instance._note_hydrated\n    instance\n  end\n\n  def attributes\n    { \"title\" => @title, \"body\" => @body, \"created_at\" => @created_at_raw.not_nil!, \"updated_at\" => @updated_at_raw.not_nil! }\n  end\n\n  def [](name)\n    case name\n    when :id\n      @id\n    when :title\n      (@title).as?(String?)\n    when :body\n      (@body).as?(String?)\n    when :created_at\n      @created_at_raw\n    when :updated_at\n      @updated_at_raw\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 :title\n      @title = (value).as?(String?)\n    when :body\n      @body = (value).as?(String?)\n    when :created_at\n      @created_at_raw = (value).as?(String).not_nil!\n    when :updated_at\n      @updated_at_raw = (value).as?(String).not_nil!\n    end\n  end\n\n  def update(attrs)\n    self.title = (attrs[:title]).as?(String?) if attrs.has_key?(:title)\n    self.body = (attrs[:body]).as?(String?) if attrs.has_key?(:body)\n    self.created_at_raw = (attrs[:created_at]).as?(String).not_nil! if attrs.has_key?(:created_at)\n    self.created_at_raw = Roundhouse::DateTime.format_db_time((attrs[:created_at]).as?(Time).not_nil!) if attrs.has_key?(:created_at)\n    self.updated_at_raw = (attrs[:updated_at]).as?(String).not_nil! if attrs.has_key?(:updated_at)\n    self.updated_at_raw = Roundhouse::DateTime.format_db_time((attrs[:updated_at]).as?(Time).not_nil!) if attrs.has_key?(:updated_at)\n    save.not_nil!\n  end\n\n  def update!(attrs)\n    self.title = (attrs[:title]).as?(String?) if attrs.has_key?(:title)\n    self.body = (attrs[:body]).as?(String?) if attrs.has_key?(:body)\n    self.created_at_raw = (attrs[:created_at]).as?(String).not_nil! if attrs.has_key?(:created_at)\n    self.created_at_raw = Roundhouse::DateTime.format_db_time((attrs[:created_at]).as?(Time).not_nil!) if attrs.has_key?(:created_at)\n    self.updated_at_raw = (attrs[:updated_at]).as?(String).not_nil! if attrs.has_key?(:updated_at)\n    self.updated_at_raw = Roundhouse::DateTime.format_db_time((attrs[:updated_at]).as?(Time).not_nil!) if attrs.has_key?(:updated_at)\n    save!\n    self\n  end\n\n  def fill_timestamps(creating : Bool) : Nil\n    now = Roundhouse::DateTime.db_now.not_nil!\n    @updated_at_raw = now if !(creating) || @updated_at_raw.nil? || @updated_at_raw.not_nil!.empty?\n    @created_at_raw = now if creating && @created_at_raw.nil? || @created_at_raw.not_nil!.empty?\n  end\n\n  def self._adapter_find_by_id(id : Int64)\n    stmt = Roundhouse::Db.prepare(\"SELECT id, title, body, created_at, 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, title, body, created_at, 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 self._adapter_last\n    stmt = Roundhouse::Db.prepare(\"SELECT id, title, body, created_at, updated_at FROM articles ORDER BY id DESC 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 _adapter_insert : Int64\n    Roundhouse::Db.exec(\"INSERT INTO articles (title, body, created_at, updated_at) VALUES (\" + Roundhouse::Db.escape_string_opt(@title) + \", \" + Roundhouse::Db.escape_string_opt(@body) + \", \" + Roundhouse::Db.escape_string(@created_at_raw.not_nil!) + \", \" + Roundhouse::Db.escape_string(@updated_at_raw.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 title = \" + Roundhouse::Db.escape_string_opt(@title) + \", body = \" + Roundhouse::Db.escape_string_opt(@body) + \", created_at = \" + Roundhouse::Db.escape_string(@created_at_raw.not_nil!) + \", updated_at = \" + Roundhouse::Db.escape_string(@updated_at_raw.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_any? : Bool\n    stmt = Roundhouse::Db.prepare(\"SELECT 1 FROM articles LIMIT 1\")\n    result = Roundhouse::Db.step?(stmt)\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 self.delete_all : Nil\n    Roundhouse::Db.exec(\"DELETE FROM articles\")\n    nil\n  end\n\n  def _adapter_reload\n    stmt = Roundhouse::Db.prepare(\"SELECT id, title, body, created_at, 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      @title = Roundhouse::Db.column_text_opt(stmt, 1_i64)\n      @body = Roundhouse::Db.column_text_opt(stmt, 2_i64)\n      @created_at_raw = Roundhouse::Db.column_text(stmt, 3_i64)\n      @updated_at_raw = 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._columns_sql : String\n    \"\\\"articles\\\".\\\"id\\\" AS id, \\\"articles\\\".\\\"title\\\" AS title, \\\"articles\\\".\\\"body\\\" AS body, \\\"articles\\\".\\\"created_at\\\" AS created_at, \\\"articles\\\".\\\"updated_at\\\" AS updated_at\"\n  end\n\n  def self._hydrate_all(sql : String)\n    stmt = Roundhouse::Db.prepare(sql)\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 self.from_params(p : ArticleParams)\n    instance = Article.new\n    instance.title = p.title.not_nil! if p.title_provided.not_nil!\n    instance.body = p.body.not_nil! if p.body_provided.not_nil!\n    instance\n  end\n\n  def update_from_article_params(p : ArticleParams) : Bool\n    if !(p.title_provided.not_nil!)\n      nil\n    else\n      self.title = p.title.not_nil!\n    end\n    if !(p.body_provided.not_nil!)\n      nil\n    else\n      self.body = p.body.not_nil!\n    end\n    save.not_nil!\n  end\n\n  def update_from_article_params!(p : ArticleParams)\n    if !(p.title_provided.not_nil!)\n      nil\n    else\n      self.title = p.title.not_nil!\n    end\n    if !(p.body_provided.not_nil!)\n      nil\n    else\n      self.body = p.body.not_nil!\n    end\n    save!\n    self\n  end\n\n  def validate : Nil\n    errors << \"Title can't be blank\" if @title.nil? || (@title.not_nil!).as?(String).not_nil!.empty?\n    errors << \"Body can't be blank\" if @body.nil? || (@body.not_nil!).as?(String).not_nil!.empty?\n    if !(@body.nil?)\n      len = (@body.not_nil!).as?(String).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, commenter, body, 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 comments_loaded? : Bool\n    @comments_loaded.not_nil!\n  end\n\n  def comments_target : Array(Comment)\n    @comments_cache.not_nil!\n  end\n\n  def comment_ids : Array(Int64)\n    comments.map { |rh_id_record| rh_id_record.id.not_nil! }\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 to_param : String\n    @id.not_nil!.to_s\n  end\n\n  def dom_record_key : String\n    @id.not_nil!.to_s\n  end\n\n  def cache_key : String\n    if persisted?.not_nil!\n      \"articles/#{@id.not_nil!}\"\n    else\n      \"articles/new\"\n    end\n  end\n\n  def cache_key_with_version : String\n    if persisted?.not_nil!\n      \"articles/#{@id.not_nil!}-#{@updated_at_raw.not_nil!}\"\n    else\n      \"articles/new-#{@updated_at_raw.not_nil!}\"\n    end\n  end\n\n  def after_create_commit : Nil\n    Broadcasts.prepend(stream: \"articles\", target: \"articles\", html: ::ActionView::ViewHelpers.broadcast_render(::ActionView::ViewHelpers.begin_broadcast_render.not_nil!, (Views::Articles.article(self)).as?(String).not_nil!))\n  end\n\n  def after_update_commit : Nil\n    Broadcasts.replace(stream: \"articles\", target: \"#{dom_prefix.not_nil!}_#{dom_record_key.not_nil!}\", html: ::ActionView::ViewHelpers.broadcast_render(::ActionView::ViewHelpers.begin_broadcast_render.not_nil!, (Views::Articles.article(self)).as?(String).not_nil!))\n  end\n\n  def after_destroy_commit : Nil\n    Broadcasts.remove(stream: \"articles\", target: \"#{dom_prefix.not_nil!}_#{dom_record_key.not_nil!}\")\n  end\nend\n"},{"path":"src/models/article_params.cr","content":"class ArticleParams\n  property title : String\n  property title_provided : Bool\n  property body : String\n  property body_provided : Bool\n\n  def initialize : Nil\n    @title_provided = false\n    @body_provided = false\n    @title = \"\"\n    @body = \"\"\n  end\n\n  def self.from_raw(params : Hash(String, Roundhouse::ParamValue))\n    sub = Params.sub(params, \"article\")\n    instance = ArticleParams.new\n    instance.title_provided = Params.provided(sub, \"title\")\n    instance.title = Params.str(sub, \"title\", \"\")\n    instance.body_provided = Params.provided(sub, \"body\")\n    instance.body = Params.str(sub, \"body\", \"\")\n    instance\n  end\n\n  def [](key)\n    case key\n    when :title\n      @title\n    when :body\n      @body\n    end\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 title : String?\n  property body : String?\n  property created_at : String\n  property updated_at : String\n\n  def initialize : Nil\n    @id = 0_i64\n    @title = nil\n    @body = nil\n    @created_at = \"\"\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.title = (row[\"title\"]).as?(String?)\n    instance.body = (row[\"body\"]).as?(String?)\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/models/comment.cr","content":"class Comment < ApplicationRecord\n  property article_id : Int64?\n  property commenter : String?\n  property body : String?\n  property created_at_raw : String?\n  property updated_at_raw : String?\n\n  def id? : Bool\n    !(@id.nil?) && @id.not_nil! != 0_i64\n  end\n\n  def article_id? : Bool\n    !(@article_id.nil?) && @article_id.not_nil! != 0_i64\n  end\n\n  def article_id_previously_changed? : Bool\n    !(saved_changes[\"article_id\"].nil?)\n  end\n\n  def saved_change_to_article_id? : Bool\n    !(saved_changes[\"article_id\"].nil?)\n  end\n\n  def article_id_previously_was\n    attribute_previously_was(\"article_id\")\n  end\n\n  def commenter? : Bool\n    !(@commenter.nil?) && (@commenter.not_nil!).as?(String).not_nil! != \"\"\n  end\n\n  def commenter_previously_changed? : Bool\n    !(saved_changes[\"commenter\"].nil?)\n  end\n\n  def saved_change_to_commenter? : Bool\n    !(saved_changes[\"commenter\"].nil?)\n  end\n\n  def commenter_previously_was\n    attribute_previously_was(\"commenter\")\n  end\n\n  def body? : Bool\n    !(@body.nil?) && (@body.not_nil!).as?(String).not_nil! != \"\"\n  end\n\n  def body_previously_changed? : Bool\n    !(saved_changes[\"body\"].nil?)\n  end\n\n  def saved_change_to_body? : Bool\n    !(saved_changes[\"body\"].nil?)\n  end\n\n  def body_previously_was\n    attribute_previously_was(\"body\")\n  end\n\n  def created_at : Time?\n    Roundhouse::DateTime.parse(@created_at_raw)\n  end\n\n  def created_at=(value : Time) : Nil\n    self.created_at_raw = Roundhouse::DateTime.format_db_time(value)\n  end\n\n  def created_at? : Bool\n    !(@created_at_raw.nil?) && @created_at_raw.not_nil! != \"\"\n  end\n\n  def created_at_previously_changed? : Bool\n    !(saved_changes[\"created_at\"].nil?)\n  end\n\n  def saved_change_to_created_at? : Bool\n    !(saved_changes[\"created_at\"].nil?)\n  end\n\n  def created_at_previously_was\n    attribute_previously_was(\"created_at\")\n  end\n\n  def updated_at : Time?\n    Roundhouse::DateTime.parse(@updated_at_raw)\n  end\n\n  def updated_at=(value : Time) : Nil\n    self.updated_at_raw = Roundhouse::DateTime.format_db_time(value)\n  end\n\n  def updated_at? : Bool\n    !(@updated_at_raw.nil?) && @updated_at_raw.not_nil! != \"\"\n  end\n\n  def updated_at_previously_changed? : Bool\n    !(saved_changes[\"updated_at\"].nil?)\n  end\n\n  def saved_change_to_updated_at? : Bool\n    !(saved_changes[\"updated_at\"].nil?)\n  end\n\n  def updated_at_previously_was\n    attribute_previously_was(\"updated_at\")\n  end\n\n  def self.table_name : String\n    \"comments\"\n  end\n\n  def self._table_sql : String\n    \"comments\"\n  end\n\n  def self.schema_columns : Array(Symbol)\n    [:id, :article_id, :commenter, :body, :created_at, :updated_at]\n  end\n\n  def self.schema_time_columns : Array(Symbol)\n    [:created_at, :updated_at]\n  end\n\n  def self.schema_date_columns : Array(Symbol)\n    [] of Symbol\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.commenter = (row.commenter).as?(String?)\n    instance.body = (row.body).as?(String?)\n    instance.created_at_raw = (row.created_at.not_nil!).as?(String).not_nil!\n    instance.updated_at_raw = (row.updated_at.not_nil!).as?(String).not_nil!\n    instance._note_hydrated\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.commenter = Roundhouse::Db.column_text_opt(stmt, 2_i64)\n    instance.body = Roundhouse::Db.column_text_opt(stmt, 3_i64)\n    instance.created_at_raw = Roundhouse::Db.column_text(stmt, 4_i64)\n    instance.updated_at_raw = Roundhouse::Db.column_text(stmt, 5_i64)\n    instance.mark_persisted!\n    instance._note_hydrated\n    instance\n  end\n\n  def attributes\n    { \"article_id\" => @article_id.not_nil!, \"commenter\" => @commenter, \"body\" => @body, \"created_at\" => @created_at_raw.not_nil!, \"updated_at\" => @updated_at_raw.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 :commenter\n      (@commenter).as?(String?)\n    when :body\n      (@body).as?(String?)\n    when :created_at\n      @created_at_raw\n    when :updated_at\n      @updated_at_raw\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 :commenter\n      @commenter = (value).as?(String?)\n    when :body\n      @body = (value).as?(String?)\n    when :created_at\n      @created_at_raw = (value).as?(String).not_nil!\n    when :updated_at\n      @updated_at_raw = (value).as?(String).not_nil!\n    end\n  end\n\n  def update(attrs)\n    self.article_id = (attrs[:article_id]).as?(Int64).not_nil! if attrs.has_key?(:article_id)\n    self.commenter = (attrs[:commenter]).as?(String?) if attrs.has_key?(:commenter)\n    self.body = (attrs[:body]).as?(String?) if attrs.has_key?(:body)\n    self.created_at_raw = (attrs[:created_at]).as?(String).not_nil! if attrs.has_key?(:created_at)\n    self.created_at_raw = Roundhouse::DateTime.format_db_time((attrs[:created_at]).as?(Time).not_nil!) if attrs.has_key?(:created_at)\n    self.updated_at_raw = (attrs[:updated_at]).as?(String).not_nil! if attrs.has_key?(:updated_at)\n    self.updated_at_raw = Roundhouse::DateTime.format_db_time((attrs[:updated_at]).as?(Time).not_nil!) if attrs.has_key?(:updated_at)\n    save.not_nil!\n  end\n\n  def update!(attrs)\n    self.article_id = (attrs[:article_id]).as?(Int64).not_nil! if attrs.has_key?(:article_id)\n    self.commenter = (attrs[:commenter]).as?(String?) if attrs.has_key?(:commenter)\n    self.body = (attrs[:body]).as?(String?) if attrs.has_key?(:body)\n    self.created_at_raw = (attrs[:created_at]).as?(String).not_nil! if attrs.has_key?(:created_at)\n    self.created_at_raw = Roundhouse::DateTime.format_db_time((attrs[:created_at]).as?(Time).not_nil!) if attrs.has_key?(:created_at)\n    self.updated_at_raw = (attrs[:updated_at]).as?(String).not_nil! if attrs.has_key?(:updated_at)\n    self.updated_at_raw = Roundhouse::DateTime.format_db_time((attrs[:updated_at]).as?(Time).not_nil!) if attrs.has_key?(:updated_at)\n    save!\n    self\n  end\n\n  def fill_timestamps(creating : Bool) : Nil\n    now = Roundhouse::DateTime.db_now.not_nil!\n    @updated_at_raw = now if !(creating) || @updated_at_raw.nil? || @updated_at_raw.not_nil!.empty?\n    @created_at_raw = now if creating && @created_at_raw.nil? || @created_at_raw.not_nil!.empty?\n  end\n\n  def self._adapter_find_by_id(id : Int64)\n    stmt = Roundhouse::Db.prepare(\"SELECT id, article_id, commenter, body, 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, commenter, body, 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 self._adapter_last\n    stmt = Roundhouse::Db.prepare(\"SELECT id, article_id, commenter, body, created_at, updated_at FROM comments ORDER BY id DESC 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 _adapter_insert : Int64\n    Roundhouse::Db.exec(\"INSERT INTO comments (article_id, commenter, body, created_at, updated_at) VALUES (\" + Roundhouse::Db.escape_int(@article_id.not_nil!) + \", \" + Roundhouse::Db.escape_string_opt(@commenter) + \", \" + Roundhouse::Db.escape_string_opt(@body) + \", \" + Roundhouse::Db.escape_string(@created_at_raw.not_nil!) + \", \" + Roundhouse::Db.escape_string(@updated_at_raw.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!) + \", commenter = \" + Roundhouse::Db.escape_string_opt(@commenter) + \", body = \" + Roundhouse::Db.escape_string_opt(@body) + \", created_at = \" + Roundhouse::Db.escape_string(@created_at_raw.not_nil!) + \", updated_at = \" + Roundhouse::Db.escape_string(@updated_at_raw.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_any? : Bool\n    stmt = Roundhouse::Db.prepare(\"SELECT 1 FROM comments LIMIT 1\")\n    result = Roundhouse::Db.step?(stmt)\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 self.delete_all : Nil\n    Roundhouse::Db.exec(\"DELETE FROM comments\")\n    nil\n  end\n\n  def _adapter_reload\n    stmt = Roundhouse::Db.prepare(\"SELECT id, article_id, commenter, body, 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      @commenter = Roundhouse::Db.column_text_opt(stmt, 2_i64)\n      @body = Roundhouse::Db.column_text_opt(stmt, 3_i64)\n      @created_at_raw = Roundhouse::Db.column_text(stmt, 4_i64)\n      @updated_at_raw = 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._columns_sql : String\n    \"\\\"comments\\\".\\\"id\\\" AS id, \\\"comments\\\".\\\"article_id\\\" AS article_id, \\\"comments\\\".\\\"commenter\\\" AS commenter, \\\"comments\\\".\\\"body\\\" AS body, \\\"comments\\\".\\\"created_at\\\" AS created_at, \\\"comments\\\".\\\"updated_at\\\" AS updated_at\"\n  end\n\n  def self._hydrate_all(sql : String)\n    stmt = Roundhouse::Db.prepare(sql)\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 self.from_params(p : CommentParams)\n    instance = Comment.new\n    instance.commenter = p.commenter.not_nil! if p.commenter_provided.not_nil!\n    instance.body = p.body.not_nil! if p.body_provided.not_nil!\n    instance\n  end\n\n  def update_from_comment_params(p : CommentParams) : Bool\n    if !(p.commenter_provided.not_nil!)\n      nil\n    else\n      self.commenter = p.commenter.not_nil!\n    end\n    if !(p.body_provided.not_nil!)\n      nil\n    else\n      self.body = p.body.not_nil!\n    end\n    save.not_nil!\n  end\n\n  def update_from_comment_params!(p : CommentParams)\n    if !(p.commenter_provided.not_nil!)\n      nil\n    else\n      self.commenter = p.commenter.not_nil!\n    end\n    if !(p.body_provided.not_nil!)\n      nil\n    else\n      self.body = p.body.not_nil!\n    end\n    save!\n    self\n  end\n\n  def validate : Nil\n    errors << \"Commenter can't be blank\" if @commenter.nil? || (@commenter.not_nil!).as?(String).not_nil!.empty?\n    errors << \"Body can't be blank\" if @body.nil? || (@body.not_nil!).as?(String).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, title, body, created_at, 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 article=(value : Article?) : Nil\n    if value.nil?\n      @article_id = 0_i64\n    else\n      @article_id = value.id.not_nil!\n    end\n  end\n\n  def dom_prefix : String\n    \"comment\"\n  end\n\n  def to_param : String\n    @id.not_nil!.to_s\n  end\n\n  def dom_record_key : String\n    @id.not_nil!.to_s\n  end\n\n  def cache_key : String\n    if persisted?.not_nil!\n      \"comments/#{@id.not_nil!}\"\n    else\n      \"comments/new\"\n    end\n  end\n\n  def cache_key_with_version : String\n    if persisted?.not_nil!\n      \"comments/#{@id.not_nil!}-#{@updated_at_raw.not_nil!}\"\n    else\n      \"comments/new-#{@updated_at_raw.not_nil!}\"\n    end\n  end\n\n  def after_create_commit : Nil\n    Broadcasts.append(stream: \"article_#{@article_id.not_nil!}_comments\", target: \"comments\", html: ::ActionView::ViewHelpers.broadcast_render(::ActionView::ViewHelpers.begin_broadcast_render.not_nil!, (Views::Comments.comment(self)).as?(String).not_nil!))\n    parent = article\n    return if parent.nil?\n    Broadcasts.replace(stream: \"articles\", target: \"#{parent.dom_prefix.not_nil!}_#{parent.dom_record_key.not_nil!}\", html: ::ActionView::ViewHelpers.broadcast_render(::ActionView::ViewHelpers.begin_broadcast_render.not_nil!, (Views::Articles.article(parent)).as?(String).not_nil!))\n  end\n\n  def after_update_commit : Nil\n    Broadcasts.replace(stream: \"article_#{@article_id.not_nil!}_comments\", target: \"#{dom_prefix.not_nil!}_#{dom_record_key.not_nil!}\", html: ::ActionView::ViewHelpers.broadcast_render(::ActionView::ViewHelpers.begin_broadcast_render.not_nil!, (Views::Comments.comment(self)).as?(String).not_nil!))\n  end\n\n  def after_destroy_commit : Nil\n    Broadcasts.remove(stream: \"article_#{@article_id.not_nil!}_comments\", target: \"#{dom_prefix.not_nil!}_#{dom_record_key.not_nil!}\")\n    parent = article\n    return if parent.nil?\n    Broadcasts.replace(stream: \"articles\", target: \"#{parent.dom_prefix.not_nil!}_#{parent.dom_record_key.not_nil!}\", html: ::ActionView::ViewHelpers.broadcast_render(::ActionView::ViewHelpers.begin_broadcast_render.not_nil!, (Views::Articles.article(parent)).as?(String).not_nil!))\n  end\nend\n"},{"path":"src/models/comment_params.cr","content":"class CommentParams\n  property commenter : String\n  property commenter_provided : Bool\n  property body : String\n  property body_provided : Bool\n\n  def initialize : Nil\n    @commenter_provided = false\n    @body_provided = false\n    @commenter = \"\"\n    @body = \"\"\n  end\n\n  def self.from_raw(params : Hash(String, Roundhouse::ParamValue))\n    sub = Params.sub(params, \"comment\")\n    instance = CommentParams.new\n    instance.commenter_provided = Params.provided(sub, \"commenter\")\n    instance.commenter = Params.str(sub, \"commenter\", \"\")\n    instance.body_provided = Params.provided(sub, \"body\")\n    instance.body = Params.str(sub, \"body\", \"\")\n    instance\n  end\n\n  def [](key)\n    case key\n    when :commenter\n      @commenter\n    when :body\n      @body\n    end\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 commenter : String?\n  property body : 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    @commenter = nil\n    @body = nil\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.commenter = (row[\"commenter\"]).as?(String?)\n    instance.body = (row[\"body\"]).as?(String?)\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/params.cr","content":"# Narrowing accessors over the recursive request-params tree.\n#\n# `Roundhouse::ParamValue` was a per-target TYPE WITH NO OPERATIONS, so\n# every consumer narrowed at its own call site with `is_a?`, and each\n# emitter recognized that as an IDIOM (a type test in `If`-condition\n# position whose branches ARE the narrowed value) rather than modeling\n# the test. Move the same fact to any other position and it falls off a\n# cliff — measured, three spellings each broke a different target.\n#\n# So the operations live beside the type, hand-written per target the\n# way `Db` is: these bodies inspect this target's representation, which\n# is what makes them a primitive rather than framework logic.\n#\n# Semantics fixed by a Rails 8.1 oracle: `permit` keeps a blank \"\" and\n# `Parameters#compact` drops only an explicit nil. So blank counts as\n# provided; absent, JSON null, and non-scalar do not.\n\nmodule Params\n  def self.sub(params : Hash(String, Roundhouse::ParamValue), key : String) : Hash(String, Roundhouse::ParamValue)\n    value = params[key]?\n    value.is_a?(Hash(String, Roundhouse::ParamValue)) ? value : Hash(String, Roundhouse::ParamValue).new\n  end\n\n  def self.str(sub : Hash(String, Roundhouse::ParamValue), key : String, fallback : String) : String\n    value = sub[key]?\n    value.is_a?(String) ? value : fallback\n  end\n\n  def self.provided(sub : Hash(String, Roundhouse::ParamValue), key : String) : Bool\n    sub[key]?.is_a?(String)\n  end\nend\n"},{"path":"src/route_helpers.cr","content":"module RouteHelpers\n  def self.root_path : String\n    \"/\"\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\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_json_path(id : Int64) : String\n    \"/articles/#{id}\" + \".json\"\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      property req_format : Symbol?\n      property int_params : String\n      property pattern_parts : Array(String)\n\n      def initialize(verb : String, pattern : String, controller : Symbol, action : Symbol, req_format : Symbol? = nil, int_params : String = \"\") : Nil\n        @verb = verb\n        @pattern = pattern\n        @controller = controller\n        @action = action\n        @req_format = req_format\n        @int_params = int_params\n        @pattern_parts = pattern.split(\"/\")\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      property req_format : Symbol?\n\n      def initialize(controller : Symbol, action : Symbol, path_params : Hash(String, String), req_format : Symbol? = nil) : Nil\n        @controller = controller\n        @action = action\n        @path_params = path_params\n        @req_format = req_format\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      dotted = path.split(\".\")\n      if dotted.size >= 2_i64\n        ext = dotted[dotted.size - 1_i64]\n        if !(ext.includes?(\"/\")) && format_extension(ext)\n          stripped = match_path(method, path[0_i64, path.size - ext.size - 1_i64].to_s, table, ext)\n          if stripped.nil?\n            nil\n          else\n            return stripped\n          end\n        end\n      end\n      match_path(method, path, table, \"\")\n    end\n\n    def self.format_extension(ext : String) : Bool\n      known = \"html text js css ics csv vcf vtt png jpeg jpg gif bmp tiff svg webp mpeg mp3 mp4 ogg webm otf ttf woff woff2 pdf zip gzip xml rss atom yaml json turbo_stream\".split(\" \")\n      i = 0_i64\n      while i < known.size\n        return true if known[i] == ext\n        i += 1_i64\n      end\n      false\n    end\n\n    def self.match_path(method : String, path : String, table : Array(ActionDispatch::Router::Route), format : String) : ActionDispatch::Router::MatchResult?\n      method_upcase = method.to_s.upcase\n      path_parts = path.split(\"/\")\n      hit = scan_routes(method_upcase, path_parts, table, format)\n      return scan_routes(\"GET\", path_parts, table, format) if hit.nil? && method_upcase == \"HEAD\"\n      hit\n    end\n\n    def self.scan_routes(method_upcase : String, path_parts : Array(String), table : Array(ActionDispatch::Router::Route), format : String) : ActionDispatch::Router::MatchResult?\n      i = 0_i64\n      while i < table.size\n        route = table[i]\n        verb = route.not_nil!.verb.to_s\n        if verb == method_upcase || verb == \"ANY\" && method_upcase != \"HEAD\"\n          params = match_parts(route.not_nil!.pattern_parts, path_parts, route.not_nil!.int_params, format)\n          if params.nil?\n            nil\n          else\n            return ::ActionDispatch::Router::MatchResult.new(route.not_nil!.controller, route.not_nil!.action, params, route.not_nil!.req_format)\n          end\n        end\n        i += 1_i64\n      end\n      nil\n    end\n\n    def self.match_pattern(pattern : String, path : String, int_params : String = \"\") : Hash(String, String)?\n      match_parts(pattern.split(\"/\"), path.split(\"/\"), int_params)\n    end\n\n    def self.match_parts(pattern_parts : Array(String), path_parts : Array(String), int_params : String = \"\", format : String = \"\") : Hash(String, String)?\n      last = pattern_parts.size - 1_i64\n      globbed = false\n      if last >= 0_i64\n        tail = pattern_parts[last]\n        globbed = tail.starts_with?(\"*\")\n      end\n      if globbed\n        return if path_parts.size < pattern_parts.size\n      else\n        return if pattern_parts.size != path_parts.size\n      end\n      params = {} of String => String\n      if format.empty?\n        nil\n      else\n        params[\"format\"] = format\n      end\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..]] = glob_rest(path_parts, i)\n        else\n          if pp.starts_with?(\":\")\n            name = pp[1_i64..]\n            seg = ap.to_s\n            return if int_constrained(int_params, name) && !(digits_only(seg))\n            params[name] = ap\n          else\n            if pp != ap\n              if pp.includes?(\":\")\n                nil\n              else\n                return\n              end\n              plen = param_prefix_length(pp)\n              return if plen <= 0_i64 || ap.size <= plen\n              if ap.starts_with?(pp[0_i64, plen].to_s)\n                nil\n              else\n                return\n              end\n              params[pp[plen + 1_i64, pp.size].to_s] = ap[plen, ap.size].to_s\n            end\n          end\n        end\n        i += 1_i64\n      end\n      params\n    end\n\n    def self.glob_rest(path_parts : Array(String), from : Int64) : String\n      rest = [] of String?\n      j = from\n      while j < path_parts.size\n        seg = path_parts[j]\n        rest << seg\n        j += 1_i64\n      end\n      rest.join(\"/\")\n    end\n\n    def self.param_prefix_length(segment : String) : Int64\n      i = 0_i64\n      while i < segment.size\n        return i if segment[i, 1_i64].to_s == \":\"\n        i += 1_i64\n      end\n      -1_i64\n    end\n\n    def self.int_constrained(int_params : String, name : String) : Bool\n      return false if int_params.empty?\n      parts = int_params.split(\" \")\n      i = 0_i64\n      while i < parts.size\n        return true if parts[i] == name\n        i += 1_i64\n      end\n      false\n    end\n\n    def self.digits_only(s : String) : Bool\n      return false if s.empty?\n      i = 0_i64\n      while i < s.size\n        c = s[i, 1_i64].to_s\n        return false if c != \"0\" && c != \"1\" && c != \"2\" && c != \"3\" && c != \"4\" && c != \"5\" && c != \"6\" && c != \"7\" && c != \"8\" && c != \"9\"\n        i += 1_i64\n      end\n      true\n    end\n  end\nend\n"},{"path":"src/routes.cr","content":"module RouteTable\n  def self.table : Array(ActionDispatch::Router::Route)\n    [::ActionDispatch::Router::Route.new(\"POST\", \"/articles/:article_id/comments\", :comments, :create), ::ActionDispatch::Router::Route.new(\"DELETE\", \"/articles/:article_id/comments/:id\", :comments, :destroy), ::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(\"PUT\", \"/articles/:id\", :articles, :update), ::ActionDispatch::Router::Route.new(\"DELETE\", \"/articles/:id\", :articles, :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  title TEXT,\\n  body TEXT,\\n  created_at TEXT NOT NULL,\\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  commenter TEXT,\\n  body 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 || \"storage/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\nENCODES = { \"%\" => \"%25\", \"&\" => \"%26\", \"=\" => \"%3D\", \"+\" => \"%2B\", \" \" => \"%20\", \";\" => \"%3B\", \",\" => \"%2C\", \"\\\"\" => \"%22\" }\nENCODE_PATTERN = /[%&=+ ;,\"]/\nDECODES = { \"%25\" => \"%\", \"%26\" => \"&\", \"%3D\" => \"=\", \"%2B\" => \"+\", \"%20\" => \" \", \"%3B\" => \";\", \"%2C\" => \",\", \"%22\" => \"\\\"\", \"+\" => \" \" }\nDECODE_PATTERN = /%25|%26|%3D|%2B|%20|%3B|%2C|%22|\\+/\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 : String | Symbol) : Bool\n      @data.has_key?(key.to_s)\n    end\n\n    def has_key?(key : String | Symbol) : Bool\n      @data.has_key?(key.to_s)\n    end\n\n    def include?(key : String | Symbol) : Bool\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\n    def self.from_cookie(raw : String)\n      session = ::ActionDispatch::Session.new\n      raw.to_s.split(\"&\").each do |pair|\n        parts = pair.to_s.split(\"=\")\n        k = if parts.size > 0_i64\n          ::ActionDispatch::Session.cookie_decode(parts[0_i64].to_s)\n        else\n          \"\"\n        end\n        v = if parts.size > 1_i64\n          ::ActionDispatch::Session.cookie_decode(parts[1_i64].to_s)\n        else\n          \"\"\n        end\n        if k.empty?\n          nil\n        else\n          session[k] = v\n        end\n      end\n      session\n    end\n\n    def to_cookie : String\n      ks = to_h.keys\n      ks.map { |k| \"#{::ActionDispatch::Session.cookie_encode(k)}=#{::ActionDispatch::Session.cookie_encode(self[k].to_s)}\" }.join(\"&\")\n    end\n\n    def self.cookie_encode(s : String) : String\n      s.gsub(ENCODE_PATTERN, ENCODES)\n    end\n\n    def self.cookie_decode(s : String) : String\n      s.gsub(DECODE_PATTERN, DECODES)\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 `RouteTable.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` over the shared Dom primitive surface\n  # (`Roundhouse::Dom`, defined in test_support.cr). Presence check:\n  # the selector matches at least one node; the content form also\n  # checks a matched node's text. The stub Dom is a substring matcher,\n  # so this stays rough-but-effective for the scaffold-blog HTML shapes\n  # (`\"#articles\"`, `\".p-4\"`, `\"h1\"`). Block form yields without\n  # narrowing scope (same loose semantic as the other targets) until a\n  # real engine lands.\n  def assert_select(selector : String, content : String? = nil, msg : String? = nil) : Nil\n    nodes = Roundhouse::Dom.select(Roundhouse::Dom.parse(@__body), selector)\n    if nodes.empty?\n      fail(msg || \"expected body to match selector #{selector.inspect}\")\n      return\n    end\n    if !content.nil? && !nodes.any? { |n| Roundhouse::Dom.text(n).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  # ── 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 = RouteTable.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  # ── Dom primitive surface (the assert_select substrate) ──────────\n  #\n  # The HTML-query contract assert_select lowers to, shared in shape\n  # with the Ruby/TS/Python/Rust/Elixir twins (cross-target contract\n  # in runtime/spinel/test/test_helper.rbs). Stub: the substring\n  # matcher dressed as a Dom — `select` fabricates one synthetic node\n  # (the whole document) per fragment occurrence and `text` returns it\n  # verbatim, so presence / minimum / content checks degrade to exactly\n  # the pre-contract behavior. The upgrade path is to swap these three\n  # methods for an XML::Node-backed engine — real nodes, real CSS\n  # selectors — touching only this module; every assert_select call\n  # site (RoundhouseTest, TestResponse) stays put. Single home for the\n  # selector logic that the two test surfaces previously each copied.\n  module Dom\n    # Parse an HTML document. Stub: the document *is* its html string.\n    def self.parse(html : String) : String\n      html\n    end\n\n    # Nodes matching `selector` within `root` (a document or node).\n    # Stub: one synthetic node (the root's html) per matching element.\n    #\n    # Anchor-then-verify, kept in step with the Ruby twin in\n    # runtime/spinel/test/test_helper.rb, which carries the rationale\n    # for every rule below: `fragment_for` enumerates candidate\n    # positions and every part of the compound selector is re-checked\n    # against the START TAG the candidate sits in.\n    def self.select(root : String, selector : String) : Array(String)\n      chunk = target_chunk(selector)\n      base = chunk.split(\"[\")[0]\n      want_tag = selector_tag(base)\n      want_id = selector_id(base)\n      want_classes = selector_classes(base)\n      attrs = selector_attrs(chunk)\n      fragment = fragment_for(selector)\n      nodes = [] of String\n      return nodes if fragment.empty?\n      from = 0\n      while (i = root.index(fragment, from))\n        from = i + fragment.size\n        start = tag_start(root, i)\n        stop = root.index(\">\", i)\n        tag = root[start, (stop.nil? ? root.size : stop) - start + 1]\n        ok = tag_named?(tag, want_tag)\n        ok = false if !want_id.empty? && !tag.includes?(%(id=\"#{want_id}\"))\n        if ok && !want_classes.empty?\n          present = tag_classes(tag)\n          ok = false unless want_classes.all? { |c| present.includes?(c) }\n        end\n        ok = false unless attrs.all? { |a| tag.includes?(a) }\n        nodes << root if ok\n      end\n      nodes\n    end\n\n    # Concatenated descendant text of a node. Stub: the node verbatim.\n    def self.text(node : String) : String\n      node\n    end\n\n    # The index of the `<` that opens the tag position `i` sits inside.\n    def self.tag_start(root : String, i : Int32) : Int32\n      j = i\n      while j >= 0\n        return j if root[j] == '<'\n        j -= 1\n      end\n      0\n    end\n\n    # An empty want matches anything. The boundary check is why this is\n    # not a bare `starts_with?`: `<hr` is a prefix of `<hrefish` too.\n    def self.tag_named?(tag : String, want : String) : Bool\n      return true if want.empty?\n      return false unless tag.starts_with?(\"<\" + want)\n      after = tag[want.size + 1]?\n      after.nil? || after == ' ' || after == '>' || after == '/' || after == '\\n'\n    end\n\n    # WHOLE tokens of this start tag's `class` attribute — `.message`\n    # must not hold on `class=\"message__body\"`, and a class that is not\n    # LAST in the attribute must still match.\n    def self.tag_classes(tag : String) : Array(String)\n      at = tag.index(\"class=\\\"\")\n      return [] of String if at.nil?\n      rest = tag[(at + 7)..]\n      close = rest.index(\"\\\"\")\n      value = close.nil? ? rest : rest[0, close]\n      value.split(\" \")\n    end\n\n    # The substring `select` scans for: tag, else id, else the first\n    # class name. Just a candidate enumerator — every part is re-checked\n    # above.\n    def self.fragment_for(selector : String) : String\n      base = target_chunk(selector).split(\"[\")[0]\n      tag = selector_tag(base)\n      id = selector_id(base)\n      classes = selector_classes(base)\n      if !tag.empty?\n        \"<#{tag}\"\n      elsif !id.empty?\n        %(id=\"#{id}\")\n      elsif !classes.empty?\n        classes[0]\n      else\n        \"\"\n      end\n    end\n\n    # The chunk the assertion is ABOUT — the LAST one. `assert_select \"a\n    # b\"` names a `b` inside an `a`; this engine cannot scope, so it\n    # checks the target and ignores the ancestor. Bracket-aware, because\n    # an attribute predicate may hold a space.\n    def self.target_chunk(selector : String) : String\n      best = \"\"\n      buf = \"\"\n      depth = 0\n      selector.each_char do |c|\n        if c == '['\n          depth += 1\n          buf += c\n        elsif c == ']'\n          depth -= 1 if depth > 0\n          buf += c\n        elsif depth == 0 && c.whitespace?\n          best = buf if element_chunk?(buf)\n          buf = \"\"\n        else\n          buf += c\n        end\n      end\n      best = buf if element_chunk?(buf)\n      best\n    end\n\n    def self.element_chunk?(chunk : String) : Bool\n      !chunk.empty? && chunk != \">\" && chunk != \"+\" && chunk != \"~\"\n    end\n\n    # `turbo-stream[action='append']` → ['action=\"append\"'], rendered\n    # the way an emitted start tag writes it. Both quote styles in,\n    # double quotes out; a bare `[connected]` keeps just the name.\n    def self.selector_attrs(chunk : String) : Array(String)\n      out = [] of String\n      parts = chunk.split(\"[\")\n      i = 1\n      while i < parts.size\n        pred = parts[i].split(\"]\")[0]\n        eq = pred.index(\"=\")\n        if eq.nil?\n          out << pred\n        else\n          name = pred[0, eq]\n          value = pred[(eq + 1)..].gsub(\"'\", \"\").gsub(\"\\\"\", \"\")\n          out << %(#{name}=\"#{value}\")\n        end\n        i += 1\n      end\n      out\n    end\n\n    def self.selector_tag(base : String) : String\n      without_id(base).split(\".\")[0]\n    end\n\n    def self.selector_id(base : String) : String\n      hash = base.index(\"#\")\n      return \"\" if hash.nil?\n      rest = base[(hash + 1)..]\n      dot = rest.index(\".\")\n      dot.nil? ? rest : rest[0, dot]\n    end\n\n    def self.selector_classes(base : String) : Array(String)\n      parts = without_id(base).split(\".\")\n      out = [] of String\n      i = 1\n      while i < parts.size\n        out << parts[i] unless parts[i].empty?\n        i += 1\n      end\n      out\n    end\n\n    # `hr#x.a` → `hr.a`: the id lifted out so the remainder splits on \".\"\n    # into tag + classes with no special case.\n    def self.without_id(base : String) : String\n      hash = base.index(\"#\")\n      return base if hash.nil?\n      head = base[0, hash]\n      rest = base[(hash + 1)..]\n      dot = rest.index(\".\")\n      dot.nil? ? head : head + rest[dot..]\n    end\n  end\n\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>` — the selector matches at least one\n      # node (via the shared `Dom` surface above).\n      def assert_select(selector : String) : Nil\n        if Dom.select(Dom.parse(@body), selector).empty?\n          raise \"expected body to match selector #{selector.inspect}\"\n        end\n      end\n\n      # `assert_select <selector>, <text>` — selector check + a matched\n      # node's text contains the text.\n      def assert_select_text(selector : String, text : String) : Nil\n        nodes = Dom.select(Dom.parse(@body), selector)\n        if nodes.empty?\n          raise \"expected body to match selector #{selector.inspect}\"\n        end\n        unless nodes.any? { |n| Dom.text(n).includes?(text) }\n          raise \"expected text #{text.inspect} under selector #{selector.inspect}\"\n        end\n      end\n\n      # `assert_select <selector>, minimum: N` — at least `n` matched\n      # nodes.\n      def assert_select_min(selector : String, n : Int32) : Nil\n        count = Dom.select(Dom.parse(@body), selector).size\n        if count < n\n          raise \"expected at least #{n} matches for selector #{selector.inspect}, got #{count}\"\n        end\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 = /[&<>\"']/\nBUILDER_TEXT_ESCAPES = { \"&\" => \"&amp;\", \"<\" => \"&lt;\", \">\" => \"&gt;\" }\nBUILDER_TEXT_PATTERN = /[&<>]/\nBUILDER_ATTR_ESCAPES = { \"&\" => \"&amp;\", \"<\" => \"&lt;\", \">\" => \"&gt;\", \"\\\"\" => \"&quot;\", \"\\n\" => \"&#10;\", \"\\r\" => \"&#13;\" }\nBUILDER_ATTR_PATTERN = /[&<>\"\\n\\r]/\nURL_ESCAPES = { \" \" => \"+\", \"!\" => \"%21\", \"\\\"\" => \"%22\", \"#\" => \"%23\", \"$\" => \"%24\", \"%\" => \"%25\", \"&\" => \"%26\", \"'\" => \"%27\", \"(\" => \"%28\", \")\" => \"%29\", \"*\" => \"%2A\", \"+\" => \"%2B\", \",\" => \"%2C\", \"/\" => \"%2F\", \":\" => \"%3A\", \";\" => \"%3B\", \"<\" => \"%3C\", \"=\" => \"%3D\", \">\" => \"%3E\", \"?\" => \"%3F\", \"@\" => \"%40\", \"[\" => \"%5B\", \"\\\\\" => \"%5C\", \"]\" => \"%5D\", \"^\" => \"%5E\", \"`\" => \"%60\", \"{\" => \"%7B\", \"|\" => \"%7C\", \"}\" => \"%7D\", \"\\r\" => \"%0D\", \"\\n\" => \"%0A\", \"\\0\" => \"%00\" }\nURL_ESCAPE_PATTERN = /[\\x00\\r\\n !\"\\#$%&'()*+,\\/:;<=>?@\\[\\\\\\]^`{|}]/\nURI_ESCAPES = { \" \" => \"%20\", \"!\" => \"%21\", \"\\\"\" => \"%22\", \"#\" => \"%23\", \"$\" => \"%24\", \"%\" => \"%25\", \"&\" => \"%26\", \"'\" => \"%27\", \"(\" => \"%28\", \")\" => \"%29\", \"*\" => \"%2A\", \"+\" => \"%2B\", \",\" => \"%2C\", \"/\" => \"%2F\", \":\" => \"%3A\", \";\" => \"%3B\", \"<\" => \"%3C\", \"=\" => \"%3D\", \">\" => \"%3E\", \"?\" => \"%3F\", \"@\" => \"%40\", \"[\" => \"%5B\", \"\\\\\" => \"%5C\", \"]\" => \"%5D\", \"^\" => \"%5E\", \"`\" => \"%60\", \"{\" => \"%7B\", \"|\" => \"%7C\", \"}\" => \"%7D\", \"\\r\" => \"%0D\", \"\\n\" => \"%0A\", \"\\0\" => \"%00\" }\nMAILTO_ESCAPE_PATTERN = /[ !\"\\#$%&'()*+,\\/:;<=>?\\[\\\\\\]^`{|}]/\nEMPTY_HTML_OPTS = {} of String => String\n\nmodule ActionView\n  module ViewHelpers\n    @@slots : Hash(Symbol, String) = {} of Symbol => String\n    @@broadcast_rendering : Bool = false\n\n    def self.reset_slots! : Nil\n      @@slots = {} of Symbol => String\n      @@broadcast_rendering = false\n    end\n\n    def self.content_for_set(slot : Symbol, value : String) : Nil\n      prior = get_slot(slot)\n      @@slots[slot] = prior + value\n      nil\n    end\n\n    def self.content_for_get(slot : Symbol) : String?\n      @@slots[slot]?\n    end\n\n    def self.content_for?(slot : Symbol) : Bool\n      !(get_slot(slot).empty?)\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      if s.includes?(\"&\") || s.includes?(\"<\") || s.includes?(\">\") || s.includes?(\"\\\"\") || s.includes?(\"'\")\n        nil\n      else\n        return s\n      end\n      HTML.escape(s)\n    end\n\n    def self.builder_text(s : String) : String\n      if s.includes?(\"&\") || s.includes?(\"<\") || s.includes?(\">\")\n        nil\n      else\n        return s\n      end\n      s.gsub(BUILDER_TEXT_PATTERN, BUILDER_TEXT_ESCAPES)\n    end\n\n    def self.builder_attr(s : String) : String\n      if s.includes?(\"&\") || s.includes?(\"<\") || s.includes?(\">\") || s.includes?(\"\\\"\") || s.includes?(\"\\n\") || s.includes?(\"\\r\")\n        nil\n      else\n        return s\n      end\n      s.gsub(BUILDER_ATTR_PATTERN, BUILDER_ATTR_ESCAPES)\n    end\n\n    def self.h(value)\n      html_escape(value.to_s)\n    end\n\n    def self.url_encode(s : String) : String\n      if needs_url_escape?(s)\n        nil\n      else\n        return s\n      end\n      s.gsub(URL_ESCAPE_PATTERN, URL_ESCAPES)\n    end\n\n    def self.to_query(params)\n      to_query_pairs(params, \"\")\n    end\n\n    def self.to_query_value(name, value)\n      if value.nil?\n        url_encode(name)\n      else\n        \"#{url_encode(name)}=#{url_encode(value.to_s)}\"\n      end\n    end\n\n    def self.to_query_pairs(params, namespace)\n      pairs = [] of String\n      params.each do |key, value|\n        name = if namespace.empty?\n          key.to_s\n        else\n          \"#{namespace}[#{key.to_s}]\"\n        end\n        pairs << if value.nil?\n          \"#{url_encode(name)}\"\n        else\n          \"#{url_encode(name)}=#{url_encode(value.to_s)}\"\n        end\n      end\n      pairs.join(\"&\")\n    end\n\n    def self.url_encode_component(s : String) : String\n      if needs_url_escape?(s)\n        nil\n      else\n        return s\n      end\n      s.gsub(URL_ESCAPE_PATTERN, URI_ESCAPES)\n    end\n\n    def self.url_encode_mailto_address(s : String) : String\n      if needs_mailto_escape?(s)\n        nil\n      else\n        return s\n      end\n      s.gsub(MAILTO_ESCAPE_PATTERN, URI_ESCAPES)\n    end\n\n    def self.needs_url_escape?(s : String) : Bool\n      s.includes?(\" \") || s.includes?(\"!\") || s.includes?(\"\\\"\") || s.includes?(\"#\") || s.includes?(\"$\") || s.includes?(\"%\") || s.includes?(\"&\") || s.includes?(\"'\") || s.includes?(\"(\") || s.includes?(\")\") || s.includes?(\"*\") || s.includes?(\"+\") || s.includes?(\",\") || s.includes?(\"/\") || s.includes?(\":\") || s.includes?(\";\") || s.includes?(\"<\") || s.includes?(\"=\") || s.includes?(\">\") || s.includes?(\"?\") || s.includes?(\"@\") || s.includes?(\"[\") || s.includes?(\"\\\\\") || s.includes?(\"]\") || s.includes?(\"^\") || s.includes?(\"`\") || s.includes?(\"{\") || s.includes?(\"|\") || s.includes?(\"}\") || s.includes?(\"\\r\") || s.includes?(\"\\n\") || s.includes?(\"\\0\")\n    end\n\n    def self.needs_mailto_escape?(s : String) : Bool\n      s.includes?(\" \") || s.includes?(\"!\") || s.includes?(\"\\\"\") || s.includes?(\"#\") || s.includes?(\"$\") || s.includes?(\"%\") || s.includes?(\"&\") || s.includes?(\"'\") || s.includes?(\"(\") || s.includes?(\")\") || s.includes?(\"*\") || s.includes?(\"+\") || s.includes?(\",\") || s.includes?(\"/\") || s.includes?(\":\") || s.includes?(\";\") || s.includes?(\"<\") || s.includes?(\"=\") || s.includes?(\">\") || s.includes?(\"?\") || s.includes?(\"[\") || s.includes?(\"\\\\\") || s.includes?(\"]\") || s.includes?(\"^\") || s.includes?(\"`\") || s.includes?(\"{\") || s.includes?(\"|\") || s.includes?(\"}\")\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 !(record.persisted?)\n        if suffix.nil?\n          \"new_#{record.dom_prefix}\"\n        else\n          \"#{suffix}_#{record.dom_prefix}\"\n        end\n      else\n        if suffix.nil?\n          \"#{record.dom_prefix}_#{record.dom_record_key}\"\n        else\n          \"#{suffix}_#{record.dom_prefix}_#{record.dom_record_key}\"\n        end\n      end\n    end\n\n    def self.link_to(text, href, opts = EMPTY_HTML_OPTS)\n      given = opts.to_h\n      attrs = render_attrs(given)\n      if given.has_key?(:href)\n        nil\n      else\n        attrs = attrs + \" href=\\\"\" + html_escape(href) + \"\\\"\"\n      end\n      \"<a#{attrs}>#{html_escape(text)}</a>\"\n    end\n\n    def self.link_to_if(condition, text, href, opts = EMPTY_HTML_OPTS)\n      return link_to(text, href, opts) if condition\n      html_escape(text.to_s)\n    end\n\n    def self.mail_query_append(query : String, header : String, value : String) : String\n      separator = if query.empty?\n        \"?\"\n      else\n        \"&\"\n      end\n      \"#{query}#{separator}#{header}=#{url_encode_component(value)}\"\n    end\n\n    def self.mail_to(email, name = \"\", opts = EMPTY_HTML_OPTS)\n      cc = opts[:cc]?\n      bcc = opts[:bcc]?\n      body = opts[:body]?\n      subject = opts[:subject]?\n      reply_to = opts[:reply_to]?\n      query = \"\"\n      if cc.nil?\n        nil\n      else\n        query = mail_query_append(query, \"cc\", cc.to_s)\n      end\n      if bcc.nil?\n        nil\n      else\n        query = mail_query_append(query, \"bcc\", bcc.to_s)\n      end\n      if body.nil?\n        nil\n      else\n        query = mail_query_append(query, \"body\", body.to_s)\n      end\n      if subject.nil?\n        nil\n      else\n        query = mail_query_append(query, \"subject\", subject.to_s)\n      end\n      if reply_to.nil?\n        nil\n      else\n        query = mail_query_append(query, \"reply-to\", reply_to.to_s)\n      end\n      html_opts = opts.to_h.dup\n      html_opts.delete(:cc)\n      html_opts.delete(:bcc)\n      html_opts.delete(:body)\n      html_opts.delete(:subject)\n      html_opts.delete(:reply_to)\n      href = \"mailto:#{url_encode_mailto_address(email)}#{query}\"\n      attrs = render_attrs({:href => href}.merge(html_opts))\n      label = if name.empty?\n        email\n      else\n        name\n      end\n      \"<a#{attrs}>#{html_escape(label)}</a>\"\n    end\n\n    def self.link_to_raw(text, href, opts = EMPTY_HTML_OPTS)\n      attrs = render_attrs({:href => href}.merge(opts.to_h))\n      \"<a#{attrs}>#{text}</a>\"\n    end\n\n    def self.raw(value)\n      value.to_s\n    end\n\n    def self.safe_join(parts : Array(String), separator : String = \"\") : String\n      parts.join(separator)\n    end\n\n    def self.button_to(text, href, opts = EMPTY_HTML_OPTS)\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 = csrf_token_hidden_input.not_nil!\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=\\\"#{html_escape(form_authenticity_token.not_nil!)}\\\" />\"\n    end\n\n    def self.form_authenticity_token : String\n      \"\"\n    end\n\n    def self.csp_meta_tag : String\n      \"\"\n    end\n\n    def self.stylesheet_link_tag(name, opts = EMPTY_HTML_OPTS)\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_include_tag(source, opts = EMPTY_HTML_OPTS)\n      source_s = source.to_s\n      name = if source_s.includes?(\".\")\n        source_s\n      else\n        \"#{source_s}.js\"\n      end\n      src = if name.starts_with?(\"/\") || name.includes?(\"://\")\n        name\n      else\n        \"/assets/#{name}\"\n      end\n      attrs = render_attrs({:src => src}.merge(opts.to_h))\n      \"<script#{attrs}></script>\"\n    end\n\n    def self.image_path(source : String, skip_pipeline : Bool = false) : String\n      return source if skip_pipeline\n      return source if source.starts_with?(\"/\")\n      return source if source.includes?(\"://\")\n      \"/assets/#{source}\"\n    end\n\n    def self.asset_path(source : String, skip_pipeline : Bool = false) : String\n      return source if skip_pipeline\n      return source if source.starts_with?(\"/\")\n      return source if source.includes?(\"://\")\n      \"/assets/#{source}\"\n    end\n\n    def self.url_for(target : String) : String\n      target\n    end\n\n    def self.image_url(source : String, skip_pipeline : Bool = false) : String\n      return source if skip_pipeline\n      return source if source.starts_with?(\"/\")\n      return source if source.includes?(\"://\")\n      \"/assets/#{source}\"\n    end\n\n    def self.path_to_javascript(source : String, skip_pipeline : Bool = false) : String\n      return source if skip_pipeline\n      return source if source.starts_with?(\"/\")\n      return source if source.includes?(\"://\")\n      name = if source.includes?(\".\")\n        source\n      else\n        \"#{source}.js\"\n      end\n      \"/assets/#{name}\"\n    end\n\n    def self.javascript_path(source : String, skip_pipeline : Bool = false) : String\n      return source if skip_pipeline\n      return source if source.starts_with?(\"/\")\n      return source if source.includes?(\"://\")\n      name = if source.includes?(\".\")\n        source\n      else\n        \"#{source}.js\"\n      end\n      \"/assets/#{name}\"\n    end\n\n    def self.image_tag(source, opts = EMPTY_HTML_OPTS)\n      attrs = {} of String => String | String\n      size = nil\n      opts.to_h.each do |k, v|\n        if k == :size\n          size = v\n        else\n          attrs[k] = v\n        end\n      end\n      attrs[:src] = image_path(source)\n      if !(size.nil?)\n        parts = size.to_s.split(\"x\")\n        attrs[:width] = parts[0_i64]\n        attrs[:height] = if parts.size > 1_i64\n          parts[1_i64]\n        else\n          parts[0_i64]\n        end\n      end\n      \"<img#{render_attrs(attrs)}>\"\n    end\n\n    def self.content_tag(name, content = nil, opts = EMPTY_HTML_OPTS)\n      n = name.to_s\n      \"<#{n}#{render_attrs(opts.to_h)}>#{html_escape(content.to_s)}</#{n}>\"\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, channel)\n      \"<turbo-cable-stream-source channel=\\\"#{channel}\\\" signed-stream-name=\\\"#{signed_stream_name(stream)}\\\"></turbo-cable-stream-source>\"\n    end\n\n    def self.signed_stream_name(stream)\n      Base64.strict_encode(stream.to_json) + \"--unsigned\"\n    end\n\n    def self.csrf_token_hidden_input : String\n      return \"\" if @@broadcast_rendering == true\n      \"<input type=\\\"hidden\\\" name=\\\"authenticity_token\\\" value=\\\"#{html_escape(form_authenticity_token.not_nil!)}\\\">\"\n    end\n\n    def self.cache_scope : String\n      return \"broadcast/\" if @@broadcast_rendering == true\n      \"\"\n    end\n\n    def self.begin_broadcast_render : String\n      @@broadcast_rendering = true\n      \"\"\n    end\n\n    def self.broadcast_render(_armed : String, html : String) : String\n      @@broadcast_rendering = false\n      html\n    end\n\n    def self.method_override_input(method : String | 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.form_with(opts = EMPTY_HTML_OPTS)\n      attrs = opts.to_h.dup\n      attrs.delete(:method)\n      attrs.delete(:url)\n      attrs.delete(:model)\n      attrs.delete(:scope)\n      attrs.delete(:format)\n      attrs.delete(:builder)\n      url = opts[:url]?\n      action = if url.nil?\n        \"\"\n      else\n        \" action=\\\"#{html_escape(url.to_s)}\\\"\"\n      end\n      method = opts.fetch(:method, :post)\n      \"<form#{render_attrs(attrs)}#{action} accept-charset=\\\"UTF-8\\\" method=\\\"post\\\">\" + method_override_input(method.to_s) + csrf_token_hidden_input.not_nil! + \"</form>\"\n    end\n\n    def self.optional_value_attr(value : String | Int64 | Float64 | Bool | Nil) : String\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 : String | Int64 | Float64 | Bool | Nil) : String\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      out_ = \"\"\n      attrs.each do |k, v|\n        name = k.to_s\n        if v.nil?\n          nil\n        else\n          if (v.is_a?(Hash) || v.is_a?(NamedTuple))\n            v.each do |inner_k, inner_v|\n              inner_name = inner_k.to_s.tr(\"_\", \"-\")\n              if inner_v.nil?\n                nil\n              else\n                out_ = out_ + \" #{name}-#{inner_name}=\\\"#{html_escape(inner_v.to_s)}\\\"\"\n              end\n            end\n          else\n            if boolean_attr?(name)\n              if v.to_s == \"false\"\n                nil\n              else\n                out_ = out_ + \" #{name}=\\\"#{name}\\\"\"\n              end\n            else\n              out_ = out_ + \" #{name}=\\\"#{html_escape(attr_value_text(name, v))}\\\"\"\n            end\n          end\n        end\n      end\n      out_\n    end\n\n    def self.attr_value_text(name, v)\n      v.to_s\n    end\n\n    def self.boolean_attr?(name : String) : Bool\n      name == \"allowfullscreen\" || name == \"allowpaymentrequest\" || name == \"async\" || name == \"autofocus\" || name == \"autoplay\" || name == \"checked\" || name == \"compact\" || name == \"controls\" || name == \"declare\" || name == \"default\" || name == \"defaultchecked\" || name == \"defaultmuted\" || name == \"defaultselected\" || name == \"defer\" || name == \"disabled\" || name == \"enabled\" || name == \"formnovalidate\" || name == \"hidden\" || name == \"indeterminate\" || name == \"inert\" || name == \"ismap\" || name == \"itemscope\" || name == \"loop\" || name == \"multiple\" || name == \"muted\" || name == \"nohref\" || name == \"nomodule\" || name == \"noresize\" || name == \"noshade\" || name == \"novalidate\" || name == \"nowrap\" || name == \"open\" || name == \"pauseonexit\" || name == \"playsinline\" || name == \"pubdate\" || name == \"readonly\" || name == \"required\" || name == \"reversed\" || name == \"scoped\" || name == \"seamless\" || name == \"selected\" || name == \"sortable\" || name == \"truespeed\" || name == \"typemustmatch\" || name == \"visible\"\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 class=\\\"#{\"text-blue-600 hover:underline\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_path(article.id.not_nil!))}\\\">#{::ActionView::ViewHelpers.html_escape(article.title.to_s)}</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_formatted(article.comments.size.to_s, \"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.to_s, 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 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\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_path(article.id.not_nil!))}\\\">#{\"Show\"}</a>\"\n      io << \"\\n    \"\n      io << \"<a 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\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.edit_article_path(article.id.not_nil!))}\\\">#{\"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)\n      io << \",\"\n      io << \"\\\"body\\\":\"\n      io << JsonBuilder.encode_value(article.body)\n      io << \",\"\n      io << \"\\\"created_at\\\":\"\n      io << JsonBuilder.encode_datetime(article.created_at_raw.not_nil!)\n      io << \",\"\n      io << \"\\\"updated_at\\\":\"\n      io << JsonBuilder.encode_datetime(article.updated_at_raw.not_nil!)\n      io << \",\"\n      io << \"\\\"url\\\":\"\n      io << JsonBuilder.encode_value(RouteHelpers.article_json_path(article.id.not_nil!))\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_formatted(article.errors.size.to_s, \"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 << \"          <li>\"\n          io << ::ActionView::ViewHelpers.html_escape(error.to_s)\n          io << \"</li>\\n\"\n        end\n        io << \"      </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\"}\\\">\\n#{::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 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\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.article_path(article.id.not_nil!))}\\\">#{\"Show this article\"}</a>\"\n      io << \"\\n  \"\n      io << \"<a 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\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.articles_path.not_nil!)}\\\">#{\"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\", \"Turbo::StreamsChannel\")\n      io << \"\\n\\n\"\n      ::ActionView::ViewHelpers.content_for_set(:title, \"Articles\")\n      io << \"\\n\"\n      ::ActionView::ViewHelpers.content_for_set(:head, \"<meta name=\\\"turbo-cache-control\\\" content=\\\"no-cache\\\">\")\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.to_s)\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 class=\\\"#{\"rounded-md px-3.5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white block font-medium\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.new_article_path.not_nil!)}\\\">#{\"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 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\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.articles_path.not_nil!)}\\\">#{\"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\"\n      cf_head = String::Builder.new\n      cf_head << \"  \"\n      ::ActionView::ViewHelpers.content_for_set(:head, \"<meta name=\\\"turbo-cache-control\\\" content=\\\"no-preview\\\">\")\n      cf_head << \"\\n  <meta name=\\\"article-id\\\" content=\\\"\"\n      cf_head << ::ActionView::ViewHelpers.html_escape(article.id.not_nil!.to_s)\n      cf_head << \"\\\">\\n\"\n      ::ActionView::ViewHelpers.content_for_set(:head, cf_head.to_s)\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.to_s)\n        io << \"</p>\\n\"\n      end\n      io << \"\\n  <h1 class=\\\"font-bold text-4xl\\\">\"\n      io << ::ActionView::ViewHelpers.html_escape(article.title.to_s)\n      io << \"</h1>\\n\\n  <div class=\\\"my-4\\\">\\n    <p class=\\\"text-gray-700\\\">\"\n      io << ::ActionView::ViewHelpers.html_escape(article.body.to_s)\n      io << \"</p>\\n  </div>\\n\\n  \"\n      io << \"<a 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\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.edit_article_path(article.id.not_nil!))}\\\">#{\"Edit this article\"}</a>\"\n      io << \"\\n  \"\n      io << \"<a 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\"}\\\" href=\\\"#{::ActionView::ViewHelpers.html_escape(RouteHelpers.articles_path.not_nil!)}\\\">#{\"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\", \"Turbo::StreamsChannel\")\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\"}\\\">\\n#{::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.to_s)\n      io << \"</p>\\n  <p class=\\\"text-gray-700\\\">\"\n      io << ::ActionView::ViewHelpers.html_escape(comment.body.to_s)\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\").to_s)\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":"src/views/pwa/manifest_json.cr","content":"module Views\n  module Pwa\n    def self.manifest_json(notice : String? = nil, alert : String? = nil) : String\n      io = String::Builder.new\n      io << \"{\\n  \\\"name\\\": \\\"RealBlog\\\",\\n  \\\"icons\\\": [\\n    {\\n      \\\"src\\\": \\\"/icon.png\\\",\\n      \\\"type\\\": \\\"image/png\\\",\\n      \\\"sizes\\\": \\\"512x512\\\"\\n    },\\n    {\\n      \\\"src\\\": \\\"/icon.png\\\",\\n      \\\"type\\\": \\\"image/png\\\",\\n      \\\"sizes\\\": \\\"512x512\\\",\\n      \\\"purpose\\\": \\\"maskable\\\"\\n    }\\n  ],\\n  \\\"start_url\\\": \\\"/\\\",\\n  \\\"display\\\": \\\"standalone\\\",\\n  \\\"scope\\\": \\\"/\\\",\\n  \\\"description\\\": \\\"RealBlog.\\\",\\n  \\\"theme_color\\\": \\\"red\\\",\\n  \\\"background_color\\\": \\\"red\\\"\\n}\\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;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@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{.visible{visibility:visible}.fixed{position:fixed}.static{position:static}.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)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-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}@property --tw-blur{syntax:\"*\";inherits:false}@property --tw-brightness{syntax:\"*\";inherits:false}@property --tw-contrast{syntax:\"*\";inherits:false}@property --tw-grayscale{syntax:\"*\";inherits:false}@property --tw-hue-rotate{syntax:\"*\";inherits:false}@property --tw-invert{syntax:\"*\";inherits:false}@property --tw-opacity{syntax:\"*\";inherits:false}@property --tw-saturate{syntax:\"*\";inherits:false}@property --tw-sepia{syntax:\"*\";inherits:false}@property --tw-drop-shadow{syntax:\"*\";inherits:false}@property --tw-drop-shadow-color{syntax:\"*\";inherits:false}@property --tw-drop-shadow-alpha{syntax:\"<percentage>\";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:\"*\";inherits:false}"},{"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"},{"path":"storage/.keep","content":""}]}