396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
// AshTypescript landing page animation
|
|
// Typewriter effect with syntax highlighting, inspired by ash-hq.org
|
|
|
|
const HEXDOCS = "https://hexdocs.pm/ash_typescript";
|
|
|
|
interface Stage {
|
|
name: string;
|
|
description: string;
|
|
docsPath: string;
|
|
elixir: string;
|
|
typescript: string;
|
|
}
|
|
|
|
const stages: Stage[] = [
|
|
{
|
|
name: "Type-Safe RPC",
|
|
description: "Auto-generated TypeScript functions for every Ash action with full type inference.",
|
|
docsPath: "/first-rpc-action.html",
|
|
elixir: `use Ash.Domain,
|
|
extensions: [AshTypescript.Rpc]
|
|
|
|
typescript_rpc do
|
|
resource MyApp.Todo do
|
|
rpc_action :list_todos, :read
|
|
rpc_action :create_todo, :create
|
|
rpc_action :update_todo, :update
|
|
end
|
|
end`,
|
|
typescript: `import { listTodos, createTodo } from "./ash_rpc";
|
|
|
|
const result = await listTodos({
|
|
fields: ["id", "title", { user: ["name"] }],
|
|
filter: { completed: { eq: false } },
|
|
sort: [{ field: "insertedAt", order: "desc" }],
|
|
});
|
|
|
|
if (result.success) {
|
|
// result.data is fully typed!
|
|
result.data.forEach(todo => {
|
|
console.log(todo.title, todo.user.name);
|
|
});
|
|
}`,
|
|
},
|
|
{
|
|
name: "Typed Controllers",
|
|
description: "Typed route helpers and fetch functions for Phoenix controllers.",
|
|
docsPath: "/typed-controllers.html",
|
|
elixir: `use AshTypescript.TypedController
|
|
|
|
typed_controller do
|
|
module_name MyAppWeb.SessionController
|
|
|
|
get :current_user do
|
|
run fn conn, _params ->
|
|
json(conn, current_user(conn))
|
|
end
|
|
end
|
|
|
|
post :login do
|
|
argument :email, :string, allow_nil?: false
|
|
argument :password, :string, allow_nil?: false
|
|
run fn conn, params ->
|
|
# authenticate and respond
|
|
end
|
|
end
|
|
end`,
|
|
typescript: `import {
|
|
currentUserPath,
|
|
login,
|
|
} from "./routes";
|
|
|
|
// Typed path helpers
|
|
currentUserPath(); // => "/session/current_user"
|
|
|
|
// Typed fetch functions for mutations
|
|
const result = await login({
|
|
email: "user@example.com",
|
|
password: "secret",
|
|
headers: buildCSRFHeaders(),
|
|
});`,
|
|
},
|
|
{
|
|
name: "Typed Channels",
|
|
description: "Type-safe Phoenix channel event subscriptions with Ash PubSub.",
|
|
docsPath: "/typed-channels.html",
|
|
elixir: `# Resource with PubSub
|
|
pub_sub do
|
|
prefix "posts"
|
|
publish :create, [:id],
|
|
event: "post_created",
|
|
transform: :post_summary
|
|
end
|
|
|
|
# Typed channel definition
|
|
typed_channel do
|
|
topic "org:*"
|
|
|
|
resource MyApp.Post do
|
|
publish :post_created
|
|
publish :post_updated
|
|
end
|
|
end`,
|
|
typescript: `import {
|
|
createOrgChannel,
|
|
onOrgChannelMessages,
|
|
} from "./ash_typed_channels";
|
|
|
|
const channel = createOrgChannel(socket, orgId);
|
|
|
|
const refs = onOrgChannelMessages(channel, {
|
|
post_created: (payload) => {
|
|
// payload type inferred from calculation!
|
|
addPost(payload.id, payload.title);
|
|
},
|
|
post_updated: (payload) => {
|
|
updatePost(payload.id, payload.title);
|
|
},
|
|
});`,
|
|
},
|
|
{
|
|
name: "Field Selection",
|
|
description: "Request exactly the fields you need with full type narrowing.",
|
|
docsPath: "/field-selection.html",
|
|
elixir: `# Define your resource with relationships
|
|
attributes do
|
|
uuid_primary_key :id
|
|
attribute :title, :string, public?: true
|
|
attribute :body, :string, public?: true
|
|
attribute :view_count, :integer, public?: true
|
|
end
|
|
|
|
relationships do
|
|
belongs_to :author, MyApp.User, public?: true
|
|
end
|
|
|
|
calculations do
|
|
calculate :reading_time, :integer,
|
|
expr(string_length(body) / 200)
|
|
end`,
|
|
typescript: `// Only fetch what you need - response is narrowed
|
|
const posts = await listPosts({
|
|
fields: [
|
|
"id",
|
|
"title",
|
|
"readingTime",
|
|
{ author: ["name", "avatarUrl"] },
|
|
],
|
|
});
|
|
|
|
// TypeScript knows the exact shape:
|
|
posts.data[0].title; // string ✓
|
|
posts.data[0].readingTime; // number ✓
|
|
posts.data[0].author.name; // string ✓
|
|
posts.data[0].body; // Error! Not selected`,
|
|
},
|
|
];
|
|
|
|
// Elixir syntax highlighting
|
|
function highlightElixir(code: string): string {
|
|
const tokens: { start: number; end: number; cls: string }[] = [];
|
|
const patterns: [RegExp, string][] = [
|
|
[/#[^\n]*/g, "text-gray-500"],
|
|
[/"[^"]*"/g, "text-yellow-400"],
|
|
[/\b(defmodule|def|defp|do|end|use|fn|if|else|case|cond|with|for|unless|import|alias|require)\b/g, "text-pink-400"],
|
|
[/\b(true|false|nil)\b/g, "text-purple-400"],
|
|
[/(:[a-zA-Z_][a-zA-Z0-9_?!]*)/g, "text-cyan-400"],
|
|
[/\b([A-Z][a-zA-Z0-9]*(\.[A-Z][a-zA-Z0-9]*)*)\b/g, "text-blue-400"],
|
|
[/(\|>|->|<-|=>)/g, "text-pink-400"],
|
|
];
|
|
for (const [re, cls] of patterns) {
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(code)) !== null) {
|
|
const overlaps = tokens.some(t => m!.index < t.end && m!.index + m![0].length > t.start);
|
|
if (!overlaps) tokens.push({ start: m.index, end: m.index + m[0].length, cls });
|
|
}
|
|
}
|
|
tokens.sort((a, b) => a.start - b.start);
|
|
let result = "";
|
|
let pos = 0;
|
|
for (const t of tokens) {
|
|
if (t.start > pos) result += esc(code.slice(pos, t.start));
|
|
result += `<span class="${t.cls}">${esc(code.slice(t.start, t.end))}</span>`;
|
|
pos = t.end;
|
|
}
|
|
if (pos < code.length) result += esc(code.slice(pos));
|
|
return result;
|
|
}
|
|
|
|
// TypeScript syntax highlighting
|
|
function highlightTS(code: string): string {
|
|
const tokens: { start: number; end: number; cls: string }[] = [];
|
|
const patterns: [RegExp, string][] = [
|
|
[/\/\/[^\n]*/g, "text-gray-500"],
|
|
[/"[^"]*"/g, "text-yellow-400"],
|
|
[/`[^`]*`/g, "text-yellow-400"],
|
|
[/\b(import|from|export|const|let|var|function|return|if|else|async|await|new|typeof|interface|type)\b/g, "text-pink-400"],
|
|
[/\b(true|false|null|undefined)\b/g, "text-purple-400"],
|
|
[/(=>)/g, "text-pink-400"],
|
|
];
|
|
for (const [re, cls] of patterns) {
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(code)) !== null) {
|
|
const overlaps = tokens.some(t => m!.index < t.end && m!.index + m![0].length > t.start);
|
|
if (!overlaps) tokens.push({ start: m.index, end: m.index + m[0].length, cls });
|
|
}
|
|
}
|
|
tokens.sort((a, b) => a.start - b.start);
|
|
let result = "";
|
|
let pos = 0;
|
|
for (const t of tokens) {
|
|
if (t.start > pos) result += esc(code.slice(pos, t.start));
|
|
result += `<span class="${t.cls}">${esc(code.slice(t.start, t.end))}</span>`;
|
|
pos = t.end;
|
|
}
|
|
if (pos < code.length) result += esc(code.slice(pos));
|
|
return result;
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
// Typewriter engine
|
|
function typewrite(
|
|
el: HTMLElement,
|
|
highlighted: string,
|
|
onComplete: () => void,
|
|
signal: { cancelled: boolean },
|
|
): void {
|
|
// Build a flat list of characters with their HTML wrapping
|
|
const chars: string[] = [];
|
|
let inTag = false;
|
|
let tagBuffer = "";
|
|
let openTags: string[] = [];
|
|
|
|
for (let i = 0; i < highlighted.length; i++) {
|
|
const ch = highlighted[i];
|
|
if (ch === "<") {
|
|
inTag = true;
|
|
tagBuffer = "<";
|
|
continue;
|
|
}
|
|
if (inTag) {
|
|
tagBuffer += ch;
|
|
if (ch === ">") {
|
|
inTag = false;
|
|
if (tagBuffer.startsWith("</")) {
|
|
openTags.pop();
|
|
} else {
|
|
openTags.push(tagBuffer);
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
// Handle HTML entities as single visible characters
|
|
let visibleChar = ch;
|
|
if (ch === "&") {
|
|
const semiIdx = highlighted.indexOf(";", i);
|
|
if (semiIdx !== -1 && semiIdx - i < 8) {
|
|
visibleChar = highlighted.slice(i, semiIdx + 1);
|
|
i = semiIdx;
|
|
}
|
|
}
|
|
let wrapped = visibleChar;
|
|
for (const tag of openTags) {
|
|
const cls = tag.match(/class="([^"]*)"/)?.[1] || "";
|
|
wrapped = `<span class="${cls}">${wrapped}</span>`;
|
|
}
|
|
chars.push(wrapped);
|
|
}
|
|
|
|
let idx = 0;
|
|
el.innerHTML = '<span class="inline-block w-[2px] h-[1.1em] bg-primary align-text-bottom animate-pulse"></span>';
|
|
|
|
function step() {
|
|
if (signal.cancelled) return;
|
|
if (idx >= chars.length) {
|
|
el.innerHTML = highlighted;
|
|
onComplete();
|
|
return;
|
|
}
|
|
// Insert character before cursor
|
|
const cursor = el.querySelector("span:last-child")!;
|
|
cursor.insertAdjacentHTML("beforebegin", chars[idx]);
|
|
idx++;
|
|
|
|
const c = chars[idx - 1];
|
|
const isNewline = c.includes("\n") || c === "\n";
|
|
const isSpace = c === " " || c.endsWith("> </span>");
|
|
const delay = isNewline ? 80 : isSpace ? 15 : 25 + Math.random() * 15;
|
|
setTimeout(step, delay);
|
|
}
|
|
step();
|
|
}
|
|
|
|
// Main initialization
|
|
export function initLandingPage(container: HTMLElement): () => void {
|
|
let currentStage = 0;
|
|
let signal = { cancelled: false };
|
|
let autoTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
|
|
container.innerHTML = buildHTML();
|
|
const elixirEl = container.querySelector<HTMLElement>("#elixir-code")!;
|
|
const tsEl = container.querySelector<HTMLElement>("#ts-code")!;
|
|
const descEl = container.querySelector<HTMLElement>("#stage-description")!;
|
|
const docsLink = container.querySelector<HTMLAnchorElement>("#stage-docs-link")!;
|
|
const dots = container.querySelectorAll<HTMLElement>(".stage-dot");
|
|
const tsPanel = container.querySelector<HTMLElement>("#ts-panel")!;
|
|
|
|
function showStage(index: number) {
|
|
signal.cancelled = true;
|
|
signal = { cancelled: false };
|
|
if (autoTimer) clearTimeout(autoTimer);
|
|
currentStage = index;
|
|
const stage = stages[index];
|
|
|
|
// Update dots
|
|
dots.forEach((dot, i) => {
|
|
dot.classList.toggle("bg-primary", i === index);
|
|
dot.classList.toggle("opacity-100", i === index);
|
|
dot.classList.toggle("bg-base-content", i !== index);
|
|
dot.classList.toggle("opacity-30", i !== index);
|
|
dot.classList.toggle("scale-125", i === index);
|
|
});
|
|
|
|
// Update description
|
|
descEl.textContent = stage.description;
|
|
docsLink.href = HEXDOCS + stage.docsPath;
|
|
|
|
// Reset panels — hide TS panel entirely (no layout space)
|
|
tsPanel.hidden = true;
|
|
tsPanel.style.opacity = "0";
|
|
|
|
const elixirHL = highlightElixir(stage.elixir);
|
|
const tsHL = highlightTS(stage.typescript);
|
|
|
|
if (reducedMotion) {
|
|
elixirEl.innerHTML = elixirHL;
|
|
tsEl.innerHTML = tsHL;
|
|
tsPanel.hidden = false;
|
|
tsPanel.style.opacity = "1";
|
|
autoTimer = setTimeout(() => showStage((currentStage + 1) % stages.length), 6000);
|
|
} else {
|
|
typewrite(elixirEl, elixirHL, () => {
|
|
if (signal.cancelled) return;
|
|
tsPanel.hidden = false;
|
|
requestAnimationFrame(() => { tsPanel.style.opacity = "1"; });
|
|
typewrite(tsEl, tsHL, () => {
|
|
if (signal.cancelled) return;
|
|
autoTimer = setTimeout(() => showStage((currentStage + 1) % stages.length), 4000);
|
|
}, signal);
|
|
}, signal);
|
|
}
|
|
}
|
|
|
|
// Dot click handlers
|
|
dots.forEach((dot, i) => {
|
|
dot.addEventListener("click", () => showStage(i));
|
|
});
|
|
|
|
// Start
|
|
showStage(0);
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
signal.cancelled = true;
|
|
if (autoTimer) clearTimeout(autoTimer);
|
|
};
|
|
}
|
|
|
|
function buildHTML(): string {
|
|
const dotHTML = stages.map((s, i) =>
|
|
`<button class="stage-dot w-3 h-3 rounded-full transition-all duration-300 cursor-pointer ${i === 0 ? "bg-primary opacity-100 scale-125" : "bg-base-content opacity-30"}" title="${s.name}"></button>`
|
|
).join("");
|
|
|
|
return `
|
|
<div class="mb-12">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div class="flex gap-2 items-center">${dotHTML}</div>
|
|
<a id="stage-docs-link" href="${HEXDOCS}" target="_blank" rel="noopener noreferrer" class="text-sm text-primary hover:underline">View docs →</a>
|
|
</div>
|
|
<p id="stage-description" class="text-sm opacity-70 mb-4">${stages[0].description}</p>
|
|
<div class="space-y-3">
|
|
<div class="relative">
|
|
<div class="absolute top-2 right-3 text-xs opacity-40 font-mono select-none">Elixir</div>
|
|
<pre class="bg-base-300 rounded-lg p-4 pt-8 text-sm font-mono leading-relaxed overflow-x-auto"><code id="elixir-code" class="text-gray-300"></code></pre>
|
|
</div>
|
|
<div id="ts-panel" class="relative transition-opacity duration-500" style="opacity:0" hidden>
|
|
<div class="absolute top-2 right-3 text-xs opacity-40 font-mono select-none">TypeScript</div>
|
|
<pre class="bg-base-300 rounded-lg p-4 pt-8 text-sm font-mono leading-relaxed overflow-x-auto"><code id="ts-code" class="text-gray-300"></code></pre>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|