+10
-6
packages/cli/src/commands/init.ts
+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
+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
-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
+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
+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
-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
+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
+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
+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
-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
+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
+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
-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
+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
+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
-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
+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
+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
+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
+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
+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
+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
+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;