An experimental TypeSpec syntax for Lexicon

changes

Changed files
+886 -251
packages
cli
src
commands
test
scenarios
basic
expected
lexicons
com
test
typelex
nested-init
expected
lexicons
com
myservice
typelex
parent-lexicons
expected1
app
typelex
lexicons
com
myapp
expected2
app
typelex
lexicons
com
myapp
with-external-lexicons
expected1
lexicons
com
myapp
typelex
website
+10 -6
packages/cli/src/commands/init.ts
··· 3 3 import { spawn } from "child_process"; 4 4 import { createInterface } from "readline"; 5 5 import pc from "picocolors"; 6 + import { generateExternalsFile } from "../utils/externals-generator.js"; 6 7 7 8 function gradientText(text: string): string { 8 9 const colors = [ ··· 31 32 return `import "@typelex/emitter"; 32 33 import "./externals.tsp"; 33 34 34 - namespace ${namespace}.post { 35 - @rec("tid") 35 + namespace ${namespace}.example.profile { 36 + /** My profile. */ 37 + @rec("literal:self") 36 38 model Main { 37 - @required text: string; 38 - @required createdAt: datetime; 39 + /** Free-form profile description.*/ 40 + @maxGraphemes(256) 41 + description?: string; 39 42 } 40 43 } 41 44 `; ··· 266 269 console.log(`${pc.green("✓")} Created ${pc.cyan("typelex/main.tsp")}`); 267 270 } 268 271 269 - // Always create/overwrite externals.tsp 270 - await writeFile(externalsTspPath, EXTERNALS_TSP_TEMPLATE, "utf-8"); 272 + // Generate externals.tsp with any existing external lexicons 273 + const outDir = lexiconsDir || "./lexicons"; 274 + await generateExternalsFile(namespace, cwd, outDir); 271 275 console.log(`${pc.green("✓")} Created ${pc.cyan("typelex/externals.tsp")}`); 272 276 273 277 // Add build script to package.json
+21
packages/cli/test/scenarios/basic/expected/lexicons/com/test/example/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.test.example.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "description": { 12 + "type": "string", 13 + "maxGraphemes": 256, 14 + "description": "Free-form profile description." 15 + } 16 + } 17 + }, 18 + "description": "My profile." 19 + } 20 + } 21 + }
-26
packages/cli/test/scenarios/basic/expected/lexicons/com/test/post.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "com.test.post", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "properties": { 11 - "text": { 12 - "type": "string" 13 - }, 14 - "createdAt": { 15 - "type": "string", 16 - "format": "datetime" 17 - } 18 - }, 19 - "required": [ 20 - "text", 21 - "createdAt" 22 - ] 23 - } 24 - } 25 - } 26 - }
+6 -4
packages/cli/test/scenarios/basic/expected/typelex/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 3 4 - namespace com.test.post { 5 - @rec("tid") 4 + namespace com.test.example.profile { 5 + /** My profile. */ 6 + @rec("literal:self") 6 7 model Main { 7 - @required text: string; 8 - @required createdAt: datetime; 8 + /** Free-form profile description.*/ 9 + @maxGraphemes(256) 10 + description?: string; 9 11 } 10 12 }
+21
packages/cli/test/scenarios/nested-init/expected/lexicons/com/myservice/example/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myservice.example.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "description": { 12 + "type": "string", 13 + "maxGraphemes": 256, 14 + "description": "Free-form profile description." 15 + } 16 + } 17 + }, 18 + "description": "My profile." 19 + } 20 + } 21 + }
-26
packages/cli/test/scenarios/nested-init/expected/lexicons/com/myservice/post.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "com.myservice.post", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "properties": { 11 - "text": { 12 - "type": "string" 13 - }, 14 - "createdAt": { 15 - "type": "string", 16 - "format": "datetime" 17 - } 18 - }, 19 - "required": [ 20 - "text", 21 - "createdAt" 22 - ] 23 - } 24 - } 25 - } 26 - }
+6 -4
packages/cli/test/scenarios/nested-init/expected/typelex/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 3 4 - namespace com.myservice.post { 5 - @rec("tid") 4 + namespace com.myservice.example.profile { 5 + /** My profile. */ 6 + @rec("literal:self") 6 7 model Main { 7 - @required text: string; 8 - @required createdAt: datetime; 8 + /** Free-form profile description.*/ 9 + @maxGraphemes(256) 10 + description?: string; 9 11 } 10 12 }
+6 -4
packages/cli/test/scenarios/parent-lexicons/expected1/app/typelex/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 3 4 - namespace com.myapp.post { 5 - @rec("tid") 4 + namespace com.myapp.example.profile { 5 + /** My profile. */ 6 + @rec("literal:self") 6 7 model Main { 7 - @required text: string; 8 - @required createdAt: datetime; 8 + /** Free-form profile description.*/ 9 + @maxGraphemes(256) 10 + description?: string; 9 11 } 10 12 }
+21
packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/myapp/example/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myapp.example.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "description": { 12 + "type": "string", 13 + "maxGraphemes": 256, 14 + "description": "Free-form profile description." 15 + } 16 + } 17 + }, 18 + "description": "My profile." 19 + } 20 + } 21 + }
-26
packages/cli/test/scenarios/parent-lexicons/expected1/lexicons/com/myapp/post.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "com.myapp.post", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "properties": { 11 - "text": { 12 - "type": "string" 13 - }, 14 - "createdAt": { 15 - "type": "string", 16 - "format": "datetime" 17 - } 18 - }, 19 - "required": [ 20 - "text", 21 - "createdAt" 22 - ] 23 - } 24 - } 25 - } 26 - }
+6 -3
packages/cli/test/scenarios/parent-lexicons/expected2/app/typelex/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 3 4 - namespace com.myapp.post { 5 - @rec("tid") 4 + namespace com.myapp.example.profile { 5 + /** My profile. */ 6 + @rec("literal:self") 6 7 model Main { 7 - @required text: string; 8 + /** Free-form profile description.*/ 9 + @maxGraphemes(256) 10 + description?: string; 8 11 labels?: com.atproto.label.defs.SelfLabels; 9 12 } 10 13 }
+25
packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/myapp/example/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myapp.example.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "description": { 12 + "type": "string", 13 + "maxGraphemes": 256, 14 + "description": "Free-form profile description." 15 + }, 16 + "labels": { 17 + "type": "ref", 18 + "ref": "com.atproto.label.defs#selfLabels" 19 + } 20 + } 21 + }, 22 + "description": "My profile." 23 + } 24 + } 25 + }
-25
packages/cli/test/scenarios/parent-lexicons/expected2/lexicons/com/myapp/post.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "com.myapp.post", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "properties": { 11 - "text": { 12 - "type": "string" 13 - }, 14 - "labels": { 15 - "type": "ref", 16 - "ref": "com.atproto.label.defs#selfLabels" 17 - } 18 - }, 19 - "required": [ 20 - "text" 21 - ] 22 - } 23 - } 24 - } 25 - }
+20 -6
packages/cli/test/scenarios/parent-lexicons/test.ts
··· 5 5 6 6 await project.init("com.myapp.*", { cwd: appDir }); 7 7 8 + // Verify init generated externals.tsp with existing external lexicons (before build) 9 + const externals = await project.readFile("app/typelex/externals.tsp"); 10 + if (!externals.includes("com.atproto.label.defs")) { 11 + throw new Error( 12 + "externals.tsp should contain external lexicons after init", 13 + ); 14 + } 15 + 8 16 // Verify init created a working project with default main.tsp 9 17 await project.runBuildScript({ cwd: appDir }); 10 18 await project.compareTo("expected1"); 11 19 12 - // Edit main.tsp to add a post schema with labels (simulates user editing the file) 13 - await project.writeFile("app/typelex/main.tsp", `import "@typelex/emitter"; 20 + // Edit main.tsp to add labels (simulates user editing the file) 21 + await project.writeFile( 22 + "app/typelex/main.tsp", 23 + `import "@typelex/emitter"; 14 24 import "./externals.tsp"; 15 25 16 - namespace com.myapp.post { 17 - @rec("tid") 26 + namespace com.myapp.example.profile { 27 + /** My profile. */ 28 + @rec("literal:self") 18 29 model Main { 19 - @required text: string; 30 + /** Free-form profile description.*/ 31 + @maxGraphemes(256) 32 + description?: string; 20 33 labels?: com.atproto.label.defs.SelfLabels; 21 34 } 22 35 } 23 - `); 36 + `, 37 + ); 24 38 25 39 await project.runBuildScript({ cwd: appDir }); 26 40 await project.compareTo("expected2");
+21
packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/myapp/example/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.myapp.example.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "description": { 12 + "type": "string", 13 + "maxGraphemes": 256, 14 + "description": "Free-form profile description." 15 + } 16 + } 17 + }, 18 + "description": "My profile." 19 + } 20 + } 21 + }
-26
packages/cli/test/scenarios/with-external-lexicons/expected1/lexicons/com/myapp/post.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "com.myapp.post", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "record": { 9 - "type": "object", 10 - "properties": { 11 - "text": { 12 - "type": "string" 13 - }, 14 - "createdAt": { 15 - "type": "string", 16 - "format": "datetime" 17 - } 18 - }, 19 - "required": [ 20 - "text", 21 - "createdAt" 22 - ] 23 - } 24 - } 25 - } 26 - }
+6 -4
packages/cli/test/scenarios/with-external-lexicons/expected1/typelex/main.tsp
··· 1 1 import "@typelex/emitter"; 2 2 import "./externals.tsp"; 3 3 4 - namespace com.myapp.post { 5 - @rec("tid") 4 + namespace com.myapp.example.profile { 5 + /** My profile. */ 6 + @rec("literal:self") 6 7 model Main { 7 - @required text: string; 8 - @required createdAt: datetime; 8 + /** Free-form profile description.*/ 9 + @maxGraphemes(256) 10 + description?: string; 9 11 } 10 12 }
+6
packages/cli/test/scenarios/with-external-lexicons/test.ts
··· 1 1 export async function run(project) { 2 2 await project.init("com.myapp.*"); 3 3 4 + // Verify init generated externals.tsp with existing external lexicons (before build) 5 + const externals = await project.readFile("typelex/externals.tsp"); 6 + if (!externals.includes("com.atproto.label.defs")) { 7 + throw new Error("externals.tsp should contain external lexicons after init"); 8 + } 9 + 4 10 // Verify init created a working project with default main.tsp 5 11 await project.runBuildScript(); 6 12 await project.compareTo("expected1");
+14
packages/website/src/components/CodeBlock.astro
··· 1 + --- 2 + import { highlightCode } from '../utils/shiki'; 3 + 4 + interface Props { 5 + lang: 'typespec' | 'json' | 'bash'; 6 + code?: string; 7 + } 8 + 9 + const { lang, code } = Astro.props; 10 + const codeContent = code || await Astro.slots.render('default'); 11 + const highlighted = await highlightCode(codeContent.trim(), lang); 12 + --- 13 + 14 + <pre set:html={highlighted} />
+57
packages/website/src/components/ComparisonBlock.astro
··· 1 + --- 2 + import { highlightCode } from '../utils/shiki'; 3 + import { compileToJson } from '../utils/compile'; 4 + import { createPlaygroundUrl } from '../utils/playground-url'; 5 + import stringify from 'json-stringify-pretty-compact'; 6 + import { mkdtempSync, writeFileSync, rmSync } from 'fs'; 7 + import { join } from 'path'; 8 + import { tmpdir } from 'os'; 9 + 10 + interface Props { 11 + code: string; 12 + } 13 + 14 + const { code } = Astro.props; 15 + 16 + // Create temporary file for compilation 17 + const tmpDir = mkdtempSync(join(tmpdir(), 'typelex-')); 18 + const tmpFile = join(tmpDir, 'example.tsp'); 19 + writeFileSync(tmpFile, code); 20 + 21 + let lexiconJson: string; 22 + let lexicon: string; 23 + 24 + try { 25 + lexiconJson = await compileToJson(tmpFile); 26 + lexicon = stringify(JSON.parse(lexiconJson), { maxLength: 80 }); 27 + } finally { 28 + rmSync(tmpDir, { recursive: true, force: true }); 29 + } 30 + 31 + const typelexHtml = await highlightCode(code, 'typespec'); 32 + const lexiconHtml = await highlightCode(lexicon, 'json'); 33 + const playgroundUrl = createPlaygroundUrl(code); 34 + --- 35 + 36 + <div class="comparison"> 37 + <div class="comparison-content"> 38 + <div class="code-panel"> 39 + <p class="code-header"> 40 + Typelex 41 + <a href={playgroundUrl} target="_blank" rel="noopener noreferrer" class="code-playground-link" aria-label="Open in playground"> 42 + <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"> 43 + <path d="M6.5 3.5C6.5 3.22386 6.72386 3 7 3H13C13.2761 3 13.5 3.22386 13.5 3.5V9.5C13.5 9.77614 13.2761 10 13 10C12.7239 10 12.5 9.77614 12.5 9.5V4.70711L6.85355 10.3536C6.65829 10.5488 6.34171 10.5488 6.14645 10.3536C5.95118 10.1583 5.95118 9.84171 6.14645 9.64645L11.7929 4H7C6.72386 4 6.5 3.77614 6.5 3.5Z" fill="currentColor"/> 44 + <path d="M3 5.5C3 4.67157 3.67157 4 4.5 4H5C5.27614 4 5.5 4.22386 5.5 4.5C5.5 4.77614 5.27614 5 5 5H4.5C4.22386 5 4 5.22386 4 5.5V11.5C4 11.7761 4.22386 12 4.5 12H10.5C10.7761 12 11 11.7761 11 11.5V11C11 10.7239 11.2239 10.5 11.5 10.5C11.7761 10.5 12 10.7239 12 11V11.5C12 12.3284 11.3284 13 10.5 13H4.5C3.67157 13 3 12.3284 3 11.5V5.5Z" fill="currentColor"/> 45 + </svg> 46 + </a> 47 + </p> 48 + <div class="code-block" set:html={typelexHtml} /> 49 + </div> 50 + <div class="code-panel"> 51 + <p class="code-header"> 52 + Lexicon 53 + </p> 54 + <div class="code-block" set:html={lexiconHtml} /> 55 + </div> 56 + </div> 57 + </div>
+172
packages/website/src/layouts/BaseLayout.astro
··· 1 + --- 2 + interface Props { 3 + title: string; 4 + description?: string; 5 + transparentNav?: boolean; 6 + } 7 + 8 + const { 9 + title, 10 + description = "An experimental TypeSpec syntax for AT Protocol Lexicons. Write Lexicons in a more readable syntax using TypeSpec.", 11 + transparentNav = false 12 + } = Astro.props; 13 + --- 14 + 15 + <!DOCTYPE html> 16 + <html lang="en"> 17 + <head> 18 + <meta charset="utf-8" /> 19 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 20 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 21 + <meta name="generator" content={Astro.generator} /> 22 + <title>{title}</title> 23 + <meta name="description" content={description} /> 24 + 25 + <!-- Open Graph / Facebook --> 26 + <meta property="og:type" content="website" /> 27 + <meta property="og:url" content="https://typelex.org/" /> 28 + <meta property="og:title" content={title} /> 29 + <meta property="og:description" content={description} /> 30 + <meta property="og:image" content="https://typelex.org/og.png" /> 31 + 32 + <!-- Twitter --> 33 + <meta property="twitter:card" content="summary_large_image" /> 34 + <meta property="twitter:url" content="https://typelex.org/" /> 35 + <meta property="twitter:title" content={title} /> 36 + <meta property="twitter:description" content={description} /> 37 + <meta property="twitter:image" content="https://typelex.org/og.png" /> 38 + </head> 39 + <body> 40 + <nav class:list={["top-nav", { transparent: transparentNav }]}> 41 + <div class="nav-container"> 42 + <a href="/" class="logo">typelex</a> 43 + <div class="nav-links"> 44 + <a href="#install">Install</a> 45 + <a href="https://tangled.org/@danabra.mov/typelex/blob/main/DOCS.md" target="_blank" rel="noopener noreferrer">Docs</a> 46 + <a href="https://playground.typelex.org" target="_blank" rel="noopener noreferrer">Playground</a> 47 + </div> 48 + </div> 49 + </nav> 50 + 51 + <slot /> 52 + 53 + {transparentNav && ( 54 + <script> 55 + const nav = document.querySelector('.top-nav'); 56 + const heroTitle = document.querySelector('header h1'); 57 + 58 + if (heroTitle && nav) { 59 + const handleScroll = () => { 60 + const titleRect = heroTitle.getBoundingClientRect(); 61 + 62 + if (titleRect.bottom < 0) { 63 + nav.classList.remove('transparent'); 64 + } else { 65 + nav.classList.add('transparent'); 66 + } 67 + }; 68 + 69 + window.addEventListener('scroll', handleScroll, { passive: true }); 70 + handleScroll(); 71 + } 72 + </script> 73 + )} 74 + </body> 75 + </html> 76 + 77 + <style is:global> 78 + * { 79 + margin: 0; 80 + padding: 0; 81 + box-sizing: border-box; 82 + } 83 + 84 + html { 85 + scroll-behavior: smooth; 86 + } 87 + 88 + body { 89 + font-family: system-ui, -apple-system, sans-serif; 90 + line-height: 1.6; 91 + color: #1e293b; 92 + background: #f8fafc; 93 + font-size: 16px; 94 + } 95 + 96 + @media (min-width: 768px) { 97 + body { 98 + font-size: 17px; 99 + } 100 + } 101 + 102 + .top-nav { 103 + position: sticky; 104 + top: 0; 105 + z-index: 100; 106 + background: rgba(255, 255, 255, 0.8); 107 + backdrop-filter: blur(10px); 108 + border-bottom: 1px solid #e2e8f0; 109 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); 110 + transition: all 0.3s ease; 111 + } 112 + 113 + .top-nav.transparent { 114 + background: rgba(255, 255, 255, 0); 115 + backdrop-filter: none; 116 + border-bottom-color: transparent; 117 + box-shadow: none; 118 + } 119 + 120 + .top-nav.transparent .logo { 121 + opacity: 0; 122 + transform: translateY(-100%); 123 + } 124 + 125 + .top-nav.transparent .nav-links a { 126 + opacity: 0.7; 127 + } 128 + 129 + .nav-container { 130 + max-width: 1104px; 131 + margin: 0 auto; 132 + padding: 1rem 2rem; 133 + display: flex; 134 + justify-content: space-between; 135 + align-items: center; 136 + } 137 + 138 + @media (min-width: 768px) { 139 + .nav-container { 140 + padding: 1rem 2rem; 141 + } 142 + } 143 + 144 + .logo { 145 + font-size: 1.25rem; 146 + font-weight: 800; 147 + background: linear-gradient(90deg, #4a9eff 0%, #7a8ef7 40%, #ff85c1 70%, #9b7ef7 100%); 148 + -webkit-background-clip: text; 149 + -webkit-text-fill-color: transparent; 150 + background-clip: text; 151 + text-decoration: none; 152 + transition: all 0.3s ease; 153 + } 154 + 155 + .nav-links { 156 + display: flex; 157 + gap: 1.5rem; 158 + align-items: center; 159 + } 160 + 161 + .nav-links a { 162 + color: #64748b; 163 + text-decoration: none; 164 + font-weight: 500; 165 + transition: all 0.3s ease; 166 + font-size: 0.9375rem; 167 + } 168 + 169 + .nav-links a:hover { 170 + color: #7a8ef7; 171 + } 172 + </style>
+445
packages/website/src/layouts/DocsLayout.astro
··· 1 + --- 2 + import BaseLayout from './BaseLayout.astro'; 3 + 4 + interface Props { 5 + title: string; 6 + } 7 + 8 + const { title } = Astro.props; 9 + --- 10 + 11 + <BaseLayout title={`${title} – typelex`}> 12 + <div class="docs-container"> 13 + <aside class="sidebar"> 14 + <div class="sidebar-content"> 15 + <h3>Documentation</h3> 16 + <nav class="sidebar-nav"> 17 + <a href="/docs" class:list={[{ active: Astro.url.pathname === '/docs' || Astro.url.pathname === '/docs/' }]}>Introduction</a> 18 + </nav> 19 + </div> 20 + </aside> 21 + 22 + <main class="docs-main"> 23 + <article class="docs-content"> 24 + <h1>{title}</h1> 25 + <slot /> 26 + </article> 27 + </main> 28 + </div> 29 + 30 + <script> 31 + document.addEventListener('DOMContentLoaded', () => { 32 + const scrollables = document.querySelectorAll('.code-panel:last-child .code-block'); 33 + 34 + // Update gradient mask based on scroll position 35 + scrollables.forEach(block => { 36 + const updateMask = () => { 37 + const isAtBottom = block.scrollHeight - block.scrollTop <= block.clientHeight + 5; 38 + if (isAtBottom) { 39 + block.style.maskImage = 'none'; 40 + block.style.webkitMaskImage = 'none'; 41 + } else { 42 + block.style.maskImage = 'linear-gradient(to bottom, black calc(100% - 150px), transparent 100%)'; 43 + block.style.webkitMaskImage = 'linear-gradient(to bottom, black calc(100% - 150px), transparent 100%)'; 44 + } 45 + }; 46 + 47 + block.addEventListener('scroll', updateMask); 48 + updateMask(); // Initial check 49 + }); 50 + 51 + // Freeze inner scrollable blocks while scrolling the page 52 + let scrollTimeout; 53 + const freezeInnerScroll = () => { 54 + document.body.classList.add('outer-scrolling'); 55 + 56 + clearTimeout(scrollTimeout); 57 + scrollTimeout = setTimeout(() => { 58 + document.body.classList.remove('outer-scrolling'); 59 + }, 150); 60 + }; 61 + 62 + // Listen for both scroll and wheel events to catch scrolling early 63 + window.addEventListener('scroll', freezeInnerScroll, { passive: true }); 64 + window.addEventListener('wheel', (e) => { 65 + // Only freeze if the wheel event is not inside a scrollable block 66 + const target = e.target; 67 + const isInsideScrollable = target.closest('.code-panel:last-child .code-block'); 68 + if (!isInsideScrollable) { 69 + freezeInnerScroll(); 70 + } 71 + }, { passive: true }); 72 + }); 73 + </script> 74 + </BaseLayout> 75 + 76 + <style is:global> 77 + .docs-container { 78 + max-width: 1400px; 79 + margin: 0 auto; 80 + display: grid; 81 + grid-template-columns: 250px 1fr; 82 + gap: 3rem; 83 + padding: 2rem 1.5rem; 84 + } 85 + 86 + @media (max-width: 968px) { 87 + .docs-container { 88 + grid-template-columns: 1fr; 89 + gap: 2rem; 90 + } 91 + 92 + .sidebar { 93 + position: static; 94 + border-right: none; 95 + border-bottom: 1px solid #e2e8f0; 96 + padding-bottom: 2rem; 97 + } 98 + } 99 + 100 + .sidebar { 101 + position: sticky; 102 + top: 5rem; 103 + height: fit-content; 104 + } 105 + 106 + .sidebar-content h3 { 107 + font-size: 0.875rem; 108 + text-transform: uppercase; 109 + letter-spacing: 0.05em; 110 + color: #94a3b8; 111 + margin-bottom: 1rem; 112 + font-weight: 600; 113 + } 114 + 115 + .sidebar-nav { 116 + display: flex; 117 + flex-direction: column; 118 + gap: 0.25rem; 119 + } 120 + 121 + .sidebar-nav a { 122 + color: #64748b; 123 + text-decoration: none; 124 + padding: 0.5rem 0.75rem; 125 + border-radius: 6px; 126 + transition: all 0.2s ease; 127 + font-weight: 500; 128 + } 129 + 130 + .sidebar-nav a:hover { 131 + background: #f1f5f9; 132 + color: #1e293b; 133 + } 134 + 135 + .sidebar-nav a.active { 136 + background: linear-gradient(135deg, #7a8ef7 0%, #9483f7 70%, #b87ed8 100%); 137 + color: white; 138 + font-weight: 600; 139 + } 140 + 141 + .docs-main { 142 + min-width: 0; 143 + max-width: 800px; 144 + } 145 + 146 + .docs-content { 147 + padding-bottom: 4rem; 148 + } 149 + 150 + .docs-content h1 { 151 + font-size: 2.5rem; 152 + font-weight: 800; 153 + margin: 0 0 2rem 0; 154 + background: linear-gradient(90deg, #4a9eff 0%, #7a8ef7 40%, #ff85c1 70%, #9b7ef7 100%); 155 + -webkit-background-clip: text; 156 + -webkit-text-fill-color: transparent; 157 + background-clip: text; 158 + } 159 + 160 + .docs-content h2 { 161 + font-size: 1.875rem; 162 + font-weight: 700; 163 + margin-top: 3rem; 164 + margin-bottom: 1.5rem; 165 + color: #1e293b; 166 + } 167 + 168 + .docs-content h3 { 169 + font-size: 1.5rem; 170 + font-weight: 600; 171 + margin-top: 2rem; 172 + margin-bottom: 1rem; 173 + color: #334155; 174 + } 175 + 176 + .docs-content h4 { 177 + font-size: 1.25rem; 178 + font-weight: 600; 179 + margin-top: 1.5rem; 180 + margin-bottom: 0.75rem; 181 + color: #475569; 182 + } 183 + 184 + .docs-content p { 185 + margin-bottom: 1.25rem; 186 + line-height: 1.8; 187 + color: #475569; 188 + } 189 + 190 + .docs-content a { 191 + color: #6366f1; 192 + text-decoration: none; 193 + border-bottom: 1px solid #c7d2fe; 194 + transition: all 0.2s ease; 195 + } 196 + 197 + .docs-content a:hover { 198 + color: #4f46e5; 199 + border-bottom-color: #6366f1; 200 + } 201 + 202 + .docs-content ul, .docs-content ol { 203 + margin-bottom: 1.5rem; 204 + padding-left: 2rem; 205 + } 206 + 207 + .docs-content li { 208 + margin-bottom: 0.5rem; 209 + line-height: 1.8; 210 + color: #475569; 211 + } 212 + 213 + .docs-content code { 214 + font-family: 'Monaco', 'Menlo', monospace; 215 + font-size: 0.875em; 216 + background: #f1f5f9; 217 + padding: 0.2em 0.4em; 218 + border-radius: 4px; 219 + color: #e879b9; 220 + } 221 + 222 + .docs-content pre { 223 + background: #1e1b29; 224 + border-radius: 8px; 225 + padding: 1rem; 226 + overflow-x: auto; 227 + margin-bottom: 1.5rem; 228 + } 229 + 230 + @media (min-width: 768px) { 231 + .docs-content pre { 232 + padding: 1.25rem; 233 + } 234 + } 235 + 236 + .docs-content pre code { 237 + background: transparent; 238 + padding: 0; 239 + color: inherit; 240 + font-size: 0.75rem; 241 + line-height: 1.6; 242 + } 243 + 244 + @media (min-width: 768px) { 245 + .docs-content pre code { 246 + font-size: 0.875rem; 247 + line-height: 1.7; 248 + } 249 + } 250 + 251 + .docs-content table { 252 + width: 100%; 253 + border-collapse: collapse; 254 + margin-bottom: 1.5rem; 255 + font-size: 0.9375rem; 256 + } 257 + 258 + .docs-content th, 259 + .docs-content td { 260 + text-align: left; 261 + padding: 0.75rem 1rem; 262 + border: 1px solid #e2e8f0; 263 + } 264 + 265 + .docs-content th { 266 + background: #f8fafc; 267 + font-weight: 600; 268 + color: #1e293b; 269 + } 270 + 271 + .docs-content td { 272 + color: #475569; 273 + } 274 + 275 + .docs-content blockquote { 276 + border-left: 4px solid #7a8ef7; 277 + padding-left: 1.5rem; 278 + margin: 1.5rem 0; 279 + color: #64748b; 280 + font-style: italic; 281 + } 282 + 283 + .comparison { 284 + background: #1e1b29; 285 + border-radius: 12px; 286 + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 287 + overflow: hidden; 288 + margin: 2rem 0; 289 + } 290 + 291 + .comparison-content { 292 + position: relative; 293 + padding: 0.75rem; 294 + display: grid; 295 + grid-template-columns: 1fr; 296 + gap: 1.5rem; 297 + } 298 + 299 + @media (min-width: 768px) { 300 + .comparison-content { 301 + padding: 1rem; 302 + grid-template-columns: 1fr 1fr; 303 + gap: 2rem; 304 + } 305 + } 306 + 307 + .code-panel { 308 + position: relative; 309 + min-width: 0; 310 + overflow: hidden; 311 + text-align: left; 312 + } 313 + 314 + .code-header { 315 + padding: 0.5rem 1rem; 316 + background: #252231; 317 + border-radius: 8px 8px 0 0; 318 + font-size: 0.75rem; 319 + font-weight: 600; 320 + text-transform: uppercase; 321 + letter-spacing: 0.05em; 322 + margin: 0; 323 + color: #94a3b8; 324 + display: flex; 325 + align-items: center; 326 + justify-content: space-between; 327 + } 328 + 329 + @media (min-width: 768px) { 330 + .code-header { 331 + font-size: 0.8125rem; 332 + padding: 0.625rem 1rem; 333 + } 334 + } 335 + 336 + .code-block { 337 + position: relative; 338 + text-align: left; 339 + } 340 + 341 + .code-panel:last-child .code-block { 342 + overflow-y: auto; 343 + max-height: 400px; 344 + -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 100px), transparent 100%); 345 + mask-image: linear-gradient(to bottom, black calc(100% - 100px), transparent 100%); 346 + } 347 + 348 + /* Freeze inner scrollables when scrolling the page */ 349 + body.outer-scrolling .code-panel:last-child .code-block { 350 + pointer-events: none; 351 + overflow-y: hidden; 352 + } 353 + 354 + @media (min-width: 768px) { 355 + .code-panel:first-child { 356 + position: relative; 357 + z-index: 1; 358 + } 359 + 360 + .code-panel:last-child { 361 + position: absolute; 362 + top: 1rem; 363 + bottom: 1rem; 364 + right: 1rem; 365 + left: calc(50% + 1rem); 366 + } 367 + 368 + .code-panel:last-child .code-block { 369 + max-height: none; 370 + height: 100%; 371 + padding-bottom: 1.5rem; 372 + -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 150px), transparent 100%); 373 + mask-image: linear-gradient(to bottom, black calc(100% - 150px), transparent 100%); 374 + } 375 + 376 + body.outer-scrolling .code-panel:last-child .code-block { 377 + pointer-events: none; 378 + overflow-y: hidden; 379 + } 380 + } 381 + 382 + .code-block pre { 383 + margin: 0; 384 + padding: 1rem; 385 + background: transparent !important; 386 + overflow-x: auto; 387 + overflow-y: visible; 388 + -webkit-overflow-scrolling: touch; 389 + max-width: 100%; 390 + } 391 + 392 + @media (min-width: 768px) { 393 + .code-block pre { 394 + padding: 1.5rem; 395 + } 396 + } 397 + 398 + .code-block code { 399 + font-family: 'Monaco', 'Menlo', monospace; 400 + font-size: 0.75rem !important; 401 + line-height: 1.6; 402 + white-space: pre; 403 + text-align: left; 404 + } 405 + 406 + @media (min-width: 768px) { 407 + .code-block code { 408 + font-size: 0.875rem !important; 409 + } 410 + } 411 + 412 + .code-block pre code, 413 + .code-block pre code * { 414 + font-size: inherit !important; 415 + } 416 + 417 + .code-playground-link { 418 + display: inline-flex; 419 + align-items: center; 420 + justify-content: center; 421 + color: #94a3b8; 422 + transition: all 0.2s ease; 423 + text-decoration: none; 424 + opacity: 0.4; 425 + padding: 0.125rem; 426 + border-bottom: none !important; 427 + } 428 + 429 + .code-playground-link:hover { 430 + color: #c7d2fe; 431 + opacity: 1; 432 + } 433 + 434 + .code-playground-link svg { 435 + width: 1rem; 436 + height: 1rem; 437 + } 438 + 439 + @media (min-width: 768px) { 440 + .code-playground-link svg { 441 + width: 1.125rem; 442 + height: 1.125rem; 443 + } 444 + } 445 + </style>
+23 -91
packages/website/src/pages/index.astro
··· 1 1 --- 2 + import BaseLayout from '../layouts/BaseLayout.astro'; 2 3 import { highlightCode } from '../utils/shiki'; 3 4 import { compileToJson } from '../utils/compile'; 4 5 import { createPlaygroundUrl } from '../utils/playground-url'; ··· 124 125 ); 125 126 --- 126 127 127 - <!DOCTYPE html> 128 - <html lang="en"> 129 - <head> 130 - <meta charset="utf-8" /> 131 - <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> 132 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 133 - <meta name="generator" content={Astro.generator} /> 134 - <title>typelex – An experimental TypeSpec syntax for Lexicon</title> 135 - <meta name="description" content="An experimental TypeSpec syntax for AT Protocol Lexicons. Write Lexicons in a more readable syntax using TypeSpec." /> 136 - 137 - <!-- Open Graph / Facebook --> 138 - <meta property="og:type" content="website" /> 139 - <meta property="og:url" content="https://typelex.org/" /> 140 - <meta property="og:title" content="typelex – An experimental TypeSpec syntax for Lexicon" /> 141 - <meta property="og:description" content="An experimental TypeSpec syntax for AT Protocol Lexicons. Write Lexicons in a more readable syntax using TypeSpec." /> 142 - <meta property="og:image" content="https://typelex.org/og.png" /> 143 - 144 - <!-- Twitter --> 145 - <meta property="twitter:card" content="summary_large_image" /> 146 - <meta property="twitter:url" content="https://typelex.org/" /> 147 - <meta property="twitter:title" content="typelex – An experimental TypeSpec syntax for Lexicon" /> 148 - <meta property="twitter:description" content="An experimental TypeSpec syntax for AT Protocol Lexicons. Write Lexicons in a more readable syntax using TypeSpec." /> 149 - <meta property="twitter:image" content="https://typelex.org/og.png" /> 150 - </head> 151 - <body> 152 - <main class="container"> 128 + <BaseLayout title="typelex – An experimental TypeSpec syntax for Lexicon" transparentNav={true}> 129 + <main class="container"> 153 130 <header> 154 131 <h1>typelex</h1> 155 132 <p class="tagline">An experimental <a href="https://typespec.io" target="_blank" rel="noopener noreferrer">TypeSpec</a> syntax for <a href="https://atproto.com/specs/lexicon" target="_blank" rel="noopener noreferrer">Lexicon</a></p> ··· 234 211 235 212 <nav class="hero-actions"> 236 213 <a href="#install" class="install-cta">Try It</a> 237 - <a href="https://tangled.org/@danabra.mov/typelex/blob/main/DOCS.md" target="_blank" rel="noopener noreferrer" class="star-btn"> 214 + <a target="_blank" href="https://tangled.org/@danabra.mov/typelex/blob/main/DOCS.md" class="star-btn"> 238 215 Read Docs 239 216 </a> 240 217 </nav> ··· 282 259 <div class="step-number">0</div> 283 260 <div class="step-content"> 284 261 <h3>Try the playground</h3> 285 - <p class="step-description">Experiment with typelex in your browser before installing.</p> 286 262 <a href="https://playground.typelex.org" target="_blank" rel="noopener noreferrer" class="playground-button"> 287 263 Open Playground 288 264 </a> 265 + <p class="step-description">Experiment with typelex in your browser before installing.</p> 289 266 </div> 290 267 </div> 291 268 292 269 <div class="install-step"> 293 270 <div class="step-number">1</div> 294 271 <div class="step-content"> 295 - <h3>Install packages</h3> 296 - <figure class="install-box" set:html={await highlightCode('npm install -D @typespec/compiler @typelex/emitter', 'bash')} /> 272 + <h3>Add typelex to your app</h3> 273 + <figure class="install-box" set:html={await highlightCode('npx @typelex/cli init', 'bash')} /> 274 + <p class="step-description">This will add a few things to your <code>package.json</code>, and create a <code>typelex/</code> folder.</p> 297 275 </div> 298 276 </div> 299 277 300 278 <div class="install-step"> 301 279 <div class="step-number">2</div> 302 280 <div class="step-content"> 303 - <h3>Create <code>typelex/main.tsp</code></h3> 281 + <h3>Write your lexicons in <code>typelex/main.tsp</code></h3> 304 282 <figure class="install-box install-box-with-link"> 305 283 <a href={createPlaygroundUrl(`import "@typelex/emitter"; 284 + import "./externals.tsp"; 306 285 307 - namespace com.example.actor.profile { 286 + namespace com.myapp.example.profile { 308 287 /** My profile. */ 309 288 @rec("literal:self") 310 289 model Main { ··· 319 298 </svg> 320 299 </a> 321 300 <div set:html={await highlightCode(`import "@typelex/emitter"; 301 + import "./externals.tsp"; 322 302 323 - namespace com.example.actor.profile { 303 + namespace com.myapp.example.profile { 324 304 /** My profile. */ 325 305 @rec("literal:self") 326 306 model Main { ··· 331 311 }`, 'typespec')} /> 332 312 </figure> 333 313 </div> 334 - <p class="step-description">Or grab any example Lexicon <a target=_blank href="https://playground.typelex.org/">from the Playground</a>.</p> 314 + <p class="step-description">Your app's lexicons go here. They may reference any external ones from <code>lexicons/</code>. 335 315 </div> 336 316 337 317 <div class="install-step"> 338 318 <div class="step-number">3</div> 339 319 <div class="step-content"> 340 - <h3>Create <code><a href="https://typespec.io/docs/handbook/configuration/configuration/" target="_blank" rel="noopener noreferrer">tspconfig.yaml</a></code></h3> 341 - <figure class="install-box" set:html={await highlightCode(`emit: 342 - - "@typelex/emitter" 343 - options: 344 - "@typelex/emitter": 345 - output-dir: "./lexicons"`, 'yaml')} /> 320 + <h3>Compile your lexicons</h3> 321 + <figure class="install-box" set:html={await highlightCode(`npm run build:typelex`, 'bash')} /> 322 + <p class="step-description">Your app’s compiled lexicons will appear in <code>lexicons/</code> alongside any external ones.</p> 346 323 </div> 347 324 </div> 348 325 349 326 <div class="install-step"> 350 327 <div class="step-number">4</div> 351 328 <div class="step-content"> 352 - <h3>Add a build script to <code>package.json</code></h3> 353 - <figure class="install-box" set:html={await highlightCode(`{ 354 - "scripts": { 355 - // ... 356 - "build:lexicons": "tsp compile typelex/main.tsp" 357 - } 358 - }`, 'json')} /> 359 - </div> 360 - </div> 361 - 362 - <div class="install-step"> 363 - <div class="step-number">5</div> 364 - <div class="step-content"> 365 - <h3>Generate Lexicon files</h3> 366 - <figure class="install-box" set:html={await highlightCode(`npm run build:lexicons`, 'bash')} /> 367 - <p class="step-description">Lexicon files will be generated in the <code>output-dir</code> from your <code>tspconfig.yaml</code> config.</p> 368 - </div> 369 - </div> 370 - 371 - <div class="install-step"> 372 - <div class="step-number">6</div> 373 - <div class="step-content"> 374 329 <h3>Set up VS Code</h3> 375 330 <p class="step-description">Install the <a href="https://typespec.io/docs/introduction/editor/vscode/" target="_blank" rel="noopener noreferrer">TypeSpec for VS Code extension</a> for syntax highlighting and IntelliSense.</p> 376 331 </div> 377 332 </div> 378 333 379 334 <div class="install-step"> 380 - <div class="step-number">7</div> 335 + <div class="step-number">5</div> 381 336 <div class="step-content"> 382 - <h3>Read the docs</h3> 337 + <h3>Learn more</h3> 383 338 <p class="step-description">Check out the <a href="https://tangled.org/@danabra.mov/typelex/blob/main/DOCS.md" target="_blank" rel="noopener noreferrer">documentation</a> to learn more.</p> 384 339 </div> 385 340 </div> ··· 392 347 <p>This is my personal hobby project and is not affiliated with AT or endorsed by anyone.</p> 393 348 <p>Who knows if this is a good idea?</p> 394 349 </footer> 395 - </main> 350 + </main> 396 351 397 - <script> 352 + <script> 398 353 document.addEventListener('DOMContentLoaded', () => { 399 354 const scrollables = document.querySelectorAll('.code-panel:last-child .code-block, .hero-panel:last-child .hero-code'); 400 355 ··· 437 392 } 438 393 }, { passive: true }); 439 394 }); 440 - </script> 441 - </body> 442 - </html> 395 + </script> 396 + </BaseLayout> 443 397 444 398 <style is:global> 445 - * { 446 - margin: 0; 447 - padding: 0; 448 - box-sizing: border-box; 449 - } 450 - 451 - html { 452 - scroll-behavior: smooth; 453 - } 454 - 455 399 body { 456 - font-family: system-ui, -apple-system, sans-serif; 457 - line-height: 1.6; 458 - color: #1e293b; 459 - background: #f8fafc; 460 - font-size: 16px; 461 400 position: relative; 462 401 overflow-x: hidden; 463 402 } ··· 473 412 border-radius: 50%; 474 413 pointer-events: none; 475 414 z-index: 0; 476 - } 477 - 478 - @media (min-width: 768px) { 479 - body { 480 - font-size: 17px; 481 - } 482 415 } 483 416 484 417 .container { ··· 1218 1151 1219 1152 .playground-button { 1220 1153 display: inline-block; 1221 - margin-top: 1.25rem; 1222 1154 padding: 0.875rem 2rem; 1223 1155 background: linear-gradient(135deg, #7a8ef7 0%, #9483f7 70%, #b87ed8 100%); 1224 1156 color: white;