🔥 initial commit 🔥

This commit is contained in:
2026-03-30 01:02:24 -04:00
commit cb179333f0
77 changed files with 6974 additions and 0 deletions

395
assets/js/animation.ts Normal file
View File

@@ -0,0 +1,395 @@
// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// 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 &rarr;</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>`;
}

82
assets/js/app.js Normal file
View File

@@ -0,0 +1,82 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/mixer"
import topbar from "topbar"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks},
})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", _e => keyDown = null)
window.addEventListener("click", e => {
if(keyDown === "c"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}

201
assets/js/ash_rpc.ts Normal file
View File

@@ -0,0 +1,201 @@
// Generated by AshTypescript - RPC Actions
// Do not edit this file manually
export type * from "./ash_types";
// Helper Functions
/**
* Configuration options for action RPC requests
*/
export interface ActionConfig {
// Request data
input?: Record<string, any>;
identity?: any;
fields?: Array<string | Record<string, any>>; // Field selection
filter?: Record<string, any>; // Filter options (for reads)
sort?: string | string[]; // Sort options
page?:
| {
// Offset-based pagination
limit?: number;
offset?: number;
count?: boolean;
}
| {
// Keyset pagination
limit?: number;
after?: string;
before?: string;
};
// Metadata
metadataFields?: ReadonlyArray<string>;
// HTTP customization
headers?: Record<string, string>; // Custom headers
fetchOptions?: RequestInit; // Fetch options (signal, cache, etc.)
customFetch?: (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
// Multitenancy
tenant?: string; // Tenant parameter
// Hook context
hookCtx?: Record<string, any>;
}
/**
* Configuration options for validation RPC requests
*/
export interface ValidationConfig {
// Request data
input?: Record<string, any>;
// HTTP customization
headers?: Record<string, string>;
fetchOptions?: RequestInit;
customFetch?: (
input: RequestInfo | URL,
init?: RequestInit,
) => Promise<Response>;
// Hook context
hookCtx?: Record<string, any>;
}
/**
* Gets the CSRF token from the page's meta tag
* Returns null if no CSRF token is found
*/
export function getPhoenixCSRFToken(): string | null {
return document
?.querySelector("meta[name='csrf-token']")
?.getAttribute("content") || null;
}
/**
* Builds headers object with CSRF token for Phoenix applications
* Returns headers object with X-CSRF-Token (if available)
*/
export function buildCSRFHeaders(headers: Record<string, string> = {}): Record<string, string> {
const csrfToken = getPhoenixCSRFToken();
if (csrfToken) {
headers["X-CSRF-Token"] = csrfToken;
}
return headers;
}
/**
* Internal helper function for making action RPC requests
* Handles hooks, request configuration, fetch execution, and error handling
* @param config Configuration matching ActionConfig
*/
export async function executeActionRpcRequest<T>(
payload: Record<string, any>,
config: ActionConfig
): Promise<T> {
const processedConfig = config;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...processedConfig.headers,
...config.headers,
};
const fetchFunction = config.customFetch || processedConfig.customFetch || fetch;
const fetchOptions: RequestInit = {
...processedConfig.fetchOptions,
...config.fetchOptions,
method: "POST",
headers,
body: JSON.stringify(payload),
};
const response = await fetchFunction("/rpc/run", fetchOptions);
const result = response.ok ? await response.json() : null;
if (!response.ok) {
return {
success: false,
errors: [
{
type: "network_error",
message: `Network request failed: ${response.statusText}`,
shortMessage: "Network error",
vars: { statusCode: response.status, statusText: response.statusText },
fields: [],
path: [],
details: { statusCode: response.status }
}
],
} as T;
}
return result as T;
}
/**
* Internal helper function for making validation RPC requests
* Handles hooks, request configuration, fetch execution, and error handling
* @param config Configuration matching ValidationConfig
*/
export async function executeValidationRpcRequest<T>(
payload: Record<string, any>,
config: ValidationConfig
): Promise<T> {
const processedConfig = config;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...processedConfig.headers,
...config.headers,
};
const fetchFunction = config.customFetch || processedConfig.customFetch || fetch;
const fetchOptions: RequestInit = {
...processedConfig.fetchOptions,
...config.fetchOptions,
method: "POST",
headers,
body: JSON.stringify(payload),
};
const response = await fetchFunction("/rpc/validate", fetchOptions);
const result = response.ok ? await response.json() : null;
if (!response.ok) {
return {
success: false,
errors: [
{
type: "network_error",
message: `Network request failed: ${response.statusText}`,
shortMessage: "Network error",
vars: { statusCode: response.status, statusText: response.statusText },
fields: [],
path: [],
details: { statusCode: response.status }
}
],
} as T;
}
return result as T;
}

440
assets/js/ash_types.ts Normal file
View File

@@ -0,0 +1,440 @@
// Generated by AshTypescript - Shared Types
// Do not edit this file manually
// Utility Types
// Sort string type — allows optional direction prefix on sort field names
// Prefixes per Ash.Query.sort/3: + (asc), - (desc), ++ (asc_nils_first), -- (desc_nils_last)
export type SortString<T extends string> = T | `+${T}` | `-${T}` | `++${T}` | `--${T}`;
// Resource schema constraint
export type TypedSchema = {
__type: "Resource" | "TypedMap" | "Union";
__primitiveFields: string;
};
// Utility type to convert union to intersection
export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
// Helper type to infer union field values, avoiding duplication between array and non-array unions
export type InferUnionFieldValue<
UnionSchema extends { __type: "Union"; __primitiveFields: any },
FieldSelection extends any[],
> = UnionToIntersection<
{
[FieldIndex in keyof FieldSelection]: FieldSelection[FieldIndex] extends UnionSchema["__primitiveFields"]
? FieldSelection[FieldIndex] extends keyof UnionSchema
? { [P in FieldSelection[FieldIndex]]: UnionSchema[FieldSelection[FieldIndex]] }
: never
: FieldSelection[FieldIndex] extends Record<string, any>
? {
[UnionKey in keyof FieldSelection[FieldIndex]]: UnionKey extends keyof UnionSchema
? NonNullable<UnionSchema[UnionKey]> extends { __array: true; __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
? FieldSelection[FieldIndex][UnionKey] extends any[]
? Array<
UnionToIntersection<
{
[FieldIdx in keyof FieldSelection[FieldIndex][UnionKey]]: FieldSelection[FieldIndex][UnionKey][FieldIdx] extends TypedMapFields
? FieldSelection[FieldIndex][UnionKey][FieldIdx] extends keyof NonNullable<UnionSchema[UnionKey]>
? { [P in FieldSelection[FieldIndex][UnionKey][FieldIdx]]: NonNullable<UnionSchema[UnionKey]>[P] }
: never
: never;
}[number]
>
> | null
: never
: NonNullable<UnionSchema[UnionKey]> extends { __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
? FieldSelection[FieldIndex][UnionKey] extends any[]
? UnionToIntersection<
{
[FieldIdx in keyof FieldSelection[FieldIndex][UnionKey]]: FieldSelection[FieldIndex][UnionKey][FieldIdx] extends TypedMapFields
? FieldSelection[FieldIndex][UnionKey][FieldIdx] extends keyof NonNullable<UnionSchema[UnionKey]>
? { [P in FieldSelection[FieldIndex][UnionKey][FieldIdx]]: NonNullable<UnionSchema[UnionKey]>[P] }
: never
: never;
}[number]
> | null
: never
: NonNullable<UnionSchema[UnionKey]> extends TypedSchema
? InferResult<NonNullable<UnionSchema[UnionKey]>, FieldSelection[FieldIndex][UnionKey]>
: never
: never;
}
: never;
}[number]
>;
export type HasComplexFields<T extends TypedSchema> = keyof Omit<
T,
"__primitiveFields" | "__type" | T["__primitiveFields"]
> extends never
? false
: true;
export type ComplexFieldKeys<T extends TypedSchema> = keyof Omit<
T,
"__primitiveFields" | "__type" | T["__primitiveFields"]
>;
export type LeafFieldSelection<T extends TypedSchema> = T["__primitiveFields"];
export type ComplexFieldSelection<T extends TypedSchema> = {
[K in ComplexFieldKeys<T>]?: T[K] extends {
__type: "Relationship";
__resource: infer Resource;
}
? NonNullable<Resource> extends TypedSchema
? UnifiedFieldSelection<NonNullable<Resource>>[]
: never
: T[K] extends {
__type: "ComplexCalculation";
__returnType: infer ReturnType;
}
? T[K] extends { __args: infer Args }
? NonNullable<ReturnType> extends TypedSchema
? {
args: Args;
fields: UnifiedFieldSelection<NonNullable<ReturnType>>[];
}
: { args: Args }
: NonNullable<ReturnType> extends TypedSchema
? { fields: UnifiedFieldSelection<NonNullable<ReturnType>>[] }
: never
: T[K] extends { __type: "TypedMap" }
? NonNullable<T[K]> extends TypedSchema
? UnifiedFieldSelection<NonNullable<T[K]>>[]
: never
: T[K] extends { __type: "Union"; __primitiveFields: infer PrimitiveFields }
? T[K] extends { __array: true }
? (PrimitiveFields | {
[UnionKey in keyof Omit<T[K], "__type" | "__primitiveFields" | "__array">]?: NonNullable<T[K][UnionKey]> extends { __type: "TypedMap"; __primitiveFields: any }
? NonNullable<T[K][UnionKey]>["__primitiveFields"][]
: NonNullable<T[K][UnionKey]> extends TypedSchema
? UnifiedFieldSelection<NonNullable<T[K][UnionKey]>>[]
: never;
})[]
: (PrimitiveFields | {
[UnionKey in keyof Omit<T[K], "__type" | "__primitiveFields">]?: NonNullable<T[K][UnionKey]> extends { __type: "TypedMap"; __primitiveFields: any }
? NonNullable<T[K][UnionKey]>["__primitiveFields"][]
: NonNullable<T[K][UnionKey]> extends TypedSchema
? UnifiedFieldSelection<NonNullable<T[K][UnionKey]>>[]
: never;
})[]
: NonNullable<T[K]> extends TypedSchema
? UnifiedFieldSelection<NonNullable<T[K]>>[]
: never;
};
// Main type: Use explicit base case detection to prevent infinite recursion
export type UnifiedFieldSelection<T extends TypedSchema> =
HasComplexFields<T> extends false
? LeafFieldSelection<T> // Base case: only primitives, no recursion
: LeafFieldSelection<T> | ComplexFieldSelection<T>; // Recursive case
export type InferFieldValue<
T extends TypedSchema,
Field,
> = Field extends T["__primitiveFields"]
? Field extends keyof T
? { [K in Field]: T[Field] }
: never
: Field extends Record<string, any>
? {
[K in keyof Field]: K extends keyof T
? T[K] extends {
__type: "Relationship";
__resource: infer Resource;
}
? NonNullable<Resource> extends TypedSchema
? T[K] extends { __array: true }
? Array<InferResult<NonNullable<Resource>, Field[K]>>
: null extends Resource
? InferResult<NonNullable<Resource>, Field[K]> | null
: InferResult<NonNullable<Resource>, Field[K]>
: never
: T[K] extends {
__type: "ComplexCalculation";
__returnType: infer ReturnType;
}
? NonNullable<ReturnType> extends TypedSchema
? null extends ReturnType
? InferResult<NonNullable<ReturnType>, Field[K]["fields"]> | null
: InferResult<NonNullable<ReturnType>, Field[K]["fields"]>
: ReturnType
: NonNullable<T[K]> extends { __type: "TypedMap"; __primitiveFields: infer TypedMapFields }
? NonNullable<T[K]> extends { __array: true }
? Field[K] extends any[]
? null extends T[K]
? Array<
UnionToIntersection<
{
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
? E extends TypedMapFields
? E extends keyof NonNullable<T[K]>
? { [P in E]: NonNullable<T[K]>[P] }
: never
: E extends Record<string, any>
? {
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
? null extends NonNullable<T[K]>[NestedKey]
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
: never
: never;
}
: E extends keyof NonNullable<T[K]>
? { [P in E]: NonNullable<T[K]>[P] }
: never
: never;
}[number]
>
> | null
: Array<
UnionToIntersection<
{
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
? E extends TypedMapFields
? E extends keyof NonNullable<T[K]>
? { [P in E]: NonNullable<T[K]>[P] }
: never
: E extends Record<string, any>
? {
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
? null extends NonNullable<T[K]>[NestedKey]
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
: never
: never;
}
: E extends keyof NonNullable<T[K]>
? { [P in E]: NonNullable<T[K]>[P] }
: never
: never;
}[number]
>
>
: never
: Field[K] extends any[]
? null extends T[K]
? UnionToIntersection<
{
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
? E extends TypedMapFields
? E extends keyof NonNullable<T[K]>
? { [P in E]: NonNullable<T[K]>[P] }
: never
: E extends Record<string, any>
? {
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
? null extends NonNullable<T[K]>[NestedKey]
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
: never
: never;
}
: E extends keyof NonNullable<T[K]>
? { [P in E]: NonNullable<T[K]>[P] }
: never
: never;
}[number]
> | null
: UnionToIntersection<
{
[FieldIndex in keyof Field[K]]: Field[K][FieldIndex] extends infer E
? E extends TypedMapFields
? E extends keyof T[K]
? { [P in E]: T[K][P] }
: never
: E extends Record<string, any>
? {
[NestedKey in keyof E]: NestedKey extends keyof NonNullable<T[K]>
? NonNullable<NonNullable<T[K]>[NestedKey]> extends TypedSchema
? null extends NonNullable<T[K]>[NestedKey]
? InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]> | null
: InferResult<NonNullable<NonNullable<T[K]>[NestedKey]>, E[NestedKey]>
: never
: never;
}
: E extends keyof NonNullable<T[K]>
? { [P in E]: NonNullable<T[K]>[P] }
: never
: never;
}[number]
>
: never
: T[K] extends { __type: "Union"; __primitiveFields: any }
? T[K] extends { __array: true }
? Field[K] extends any[]
? null extends T[K]
? Array<InferUnionFieldValue<T[K], Field[K]>> | null
: Array<InferUnionFieldValue<T[K], Field[K]>>
: never
: Field[K] extends any[]
? null extends T[K]
? InferUnionFieldValue<T[K], Field[K]> | null
: InferUnionFieldValue<T[K], Field[K]>
: never
: NonNullable<T[K]> extends TypedSchema
? null extends T[K]
? InferResult<NonNullable<T[K]>, Field[K]> | null
: InferResult<NonNullable<T[K]>, Field[K]>
: never
: never;
}
: never;
export type InferResult<
T extends TypedSchema,
SelectedFields extends UnifiedFieldSelection<T>[] | undefined,
> = SelectedFields extends undefined
? {}
: SelectedFields extends []
? {}
: SelectedFields extends UnifiedFieldSelection<T>[]
? UnionToIntersection<
{
[K in keyof SelectedFields]: InferFieldValue<T, SelectedFields[K]>;
}[number]
>
: {};
// Pagination conditional types
// Checks if a page configuration object has any pagination parameters
export type HasPaginationParams<Page> =
Page extends { offset: any } ? true :
Page extends { after: any } ? true :
Page extends { before: any } ? true :
false;
// Infer which pagination type is being used from the page config
export type InferPaginationType<Page> =
Page extends { offset: any } ? "offset" :
Page extends { after: any } | { before: any } ? "keyset" :
never;
// Returns either non-paginated (array) or paginated result based on page params
// For single pagination type support (offset-only or keyset-only)
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type ConditionalPaginatedResult<
Page,
RecordType,
PaginatedType
> = Page extends undefined
? RecordType
: HasPaginationParams<Page> extends true
? PaginatedType
: RecordType;
// For actions supporting both offset and keyset pagination
// Infers the specific pagination type based on which params were passed
export type ConditionalPaginatedResultMixed<
Page,
RecordType,
OffsetType,
KeysetType
> = Page extends undefined
? RecordType
: HasPaginationParams<Page> extends true
? InferPaginationType<Page> extends "offset"
? OffsetType
: InferPaginationType<Page> extends "keyset"
? KeysetType
: OffsetType | KeysetType // Fallback to union if can't determine
: RecordType;
export type SuccessDataFunc<T extends (...args: any[]) => Promise<any>> = Extract<
Awaited<ReturnType<T>>,
{ success: true }
>["data"];
export type ErrorData<T extends (...args: any[]) => Promise<any>> = Extract<
Awaited<ReturnType<T>>,
{ success: false }
>["errors"];
/**
* Represents an error from an unsuccessful RPC call.
*
* This type matches the error structure defined in the AshTypescript.Rpc.Error protocol.
*
* @example
* const error: AshRpcError = {
* type: "invalid_changes",
* message: "Invalid value for field %{field}",
* shortMessage: "Invalid changes",
* vars: { field: "email" },
* fields: ["email"],
* path: ["user", "email"],
* details: { suggestion: "Provide a valid email address" }
* }
*/
export type AshRpcError = {
/** Machine-readable error type (e.g., "invalid_changes", "not_found") */
type: string;
/** Full error message (may contain template variables like %{key}) */
message: string;
/** Concise version of the message */
shortMessage: string;
/** Variables to interpolate into the message template */
vars: Record<string, any>;
/** List of affected field names (for field-level errors) */
fields: string[];
/** Path to the error location in the data structure */
path: string[];
/** Optional map with extra details (e.g., suggestions, hints) */
details?: Record<string, any>;
}
/**
* Represents the result of a validation RPC call.
*
* All validation actions return this same structure, indicating either
* successful validation or a list of validation errors.
*
* @example
* // Successful validation
* const result: ValidationResult = { success: true };
*
* // Failed validation
* const result: ValidationResult = {
* success: false,
* errors: [
* {
* type: "required",
* message: "is required",
* shortMessage: "Required field",
* vars: { field: "email" },
* fields: ["email"],
* path: []
* }
* ]
* };
*/
export type ValidationResult =
| { success: true }
| { success: false; errors: AshRpcError[]; };

86
assets/js/index.tsx Normal file
View File

@@ -0,0 +1,86 @@
import React, { useEffect } from "react";
import { createRoot } from "react-dom/client";
import { initLandingPage } from "./animation";
function App() {
useEffect(() => {
const el = document.getElementById("animation-container");
if (el) return initLandingPage(el);
}, []);
return (
<div className="min-h-screen bg-base-100 text-base-content">
<div className="max-w-5xl mx-auto px-6 py-12">
<div className="flex items-center gap-5 mb-8">
<img
src="https://raw.githubusercontent.com/ash-project/ash_typescript/main/logos/ash-typescript.png"
alt="AshTypescript"
className="w-16 h-16"
/>
<div>
<h1 className="text-4xl font-bold">AshTypescript</h1>
<p className="text-lg opacity-70">End-to-end type safety from Ash to TypeScript</p>
</div>
</div>
<section className="mb-10">
<div className="flex flex-wrap items-center gap-3 mb-5">
<h2 className="text-2xl font-bold">Main Features</h2>
<div className="flex-1"></div>
<a href="https://hexdocs.pm/ash_typescript" target="_blank" rel="noopener noreferrer" className="btn btn-primary btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Docs
</a>
<a href="https://github.com/ash-project/ash_typescript" target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<a href="https://hexdocs.pm/ash_typescript/first-rpc-action.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div className="card-body">
<h3 className="card-title text-base">Type-Safe RPC</h3>
<p className="text-sm opacity-70">Auto-generated typed functions for every Ash action.</p>
<div className="text-sm text-primary mt-1">View docs &rarr;</div>
</div>
</a>
<a href="https://hexdocs.pm/ash_typescript/typed-controllers.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div className="card-body">
<h3 className="card-title text-base">Typed Controllers</h3>
<p className="text-sm opacity-70">Typed route helpers for Phoenix controllers.</p>
<div className="text-sm text-primary mt-1">View docs &rarr;</div>
</div>
</a>
<a href="https://hexdocs.pm/ash_typescript/typed-channels.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div className="card-body">
<h3 className="card-title text-base">Typed Channels</h3>
<p className="text-sm opacity-70">Typed event subscriptions for Phoenix channels.</p>
<div className="text-sm text-primary mt-1">View docs &rarr;</div>
</div>
</a>
<a href="https://hexdocs.pm/ash_typescript/form-validation.html" target="_blank" rel="noopener noreferrer" className="card bg-base-200 hover:bg-base-300 transition-colors cursor-pointer">
<div className="card-body">
<h3 className="card-title text-base">Zod Validation</h3>
<p className="text-sm opacity-70">Generated Zod schemas for form validation.</p>
<div className="text-sm text-primary mt-1">View docs &rarr;</div>
</div>
</a>
</div>
</section>
<div id="animation-container"></div>
</div>
</div>
);
}
createRoot(document.getElementById("app")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);