@micropage-sh/cli 2.0.2 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micropage-sh/cli",
3
- "version": "2.0.2",
3
+ "version": "2.0.8",
4
4
  "description": "CLI for micropage.sh - create, sync, and publish microsites",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -11,6 +11,7 @@ const {
11
11
  getValidAccessToken,
12
12
  getMaxDeployEventIdForBuild,
13
13
  streamDeployEventsUntilDone,
14
+ uploadAssetsWithToken,
14
15
  } = require('../supabase');
15
16
  const { getProjectConfig, setProjectConfig } = require('../auth');
16
17
  const { readPageFilesFromDir } = require('../parser');
@@ -139,6 +140,23 @@ async function push(options = {}) {
139
140
  process.exit(1);
140
141
  }
141
142
 
143
+ // Upload new files in assets/ to project Files before parsing,
144
+ // so `img: <- name` lookups can resolve against just-uploaded files.
145
+ try {
146
+ const accessToken = await getValidAccessToken();
147
+ const uploaded = await uploadAssetsWithToken(
148
+ accessToken,
149
+ config.projectId,
150
+ cwd,
151
+ (filename) => console.log(`Uploaded asset: ${filename}`),
152
+ );
153
+ if (uploaded > 0) console.log(`Uploaded ${uploaded} asset(s).`);
154
+ } catch (err) {
155
+ handleAuthError(err);
156
+ console.error('Asset upload failed:', err.message);
157
+ process.exit(1);
158
+ }
159
+
142
160
  // Parse content via parser API
143
161
  console.log('Parsing content…');
144
162
  let jsonContent;
@@ -3,13 +3,7 @@
3
3
  const { execSync } = require('child_process');
4
4
  const { db, handleAuthError } = require('../supabase');
5
5
  const { getProjectConfig } = require('../auth');
6
- const { APP_URL } = require('../config');
7
-
8
- function projectUrl(domain) {
9
- if (!domain) return null;
10
- if (domain.startsWith('http')) return domain;
11
- return `https://${domain}.pages.dev`;
12
- }
6
+ const { APP_URL, projectUrl } = require('../config');
13
7
 
14
8
  function copyToClipboard(text) {
15
9
  const cmds = [
@@ -41,7 +35,7 @@ async function getProject(cwd) {
41
35
  try {
42
36
  const project = await db
43
37
  .from('projects')
44
- .select('id,name,domain')
38
+ .select('id,name,domain,custom_domain')
45
39
  .eq('id', config.projectId)
46
40
  .single();
47
41
  return project || config;
@@ -56,7 +50,7 @@ async function getProject(cwd) {
56
50
  async function preview() {
57
51
  const cwd = process.cwd();
58
52
  const project = await getProject(cwd);
59
- const url = projectUrl(project.domain);
53
+ const url = projectUrl(project.domain, project.custom_domain);
60
54
 
61
55
  if (!url) {
62
56
  console.error('No domain set for this project. Publish first.');
@@ -76,7 +70,7 @@ async function preview() {
76
70
  async function copyLink() {
77
71
  const cwd = process.cwd();
78
72
  const project = await getProject(cwd);
79
- const url = projectUrl(project.domain);
73
+ const url = projectUrl(project.domain, project.custom_domain);
80
74
 
81
75
  if (!url) {
82
76
  console.error('No domain set for this project. Publish first.');
@@ -23,11 +23,9 @@ const { syncAssets } = require('./files');
23
23
  // Helpers
24
24
  // ---------------------------------------------------------------------------
25
25
 
26
- function projectUrl(host) {
27
- if (!host) return '-';
28
- if (host.startsWith('http://') || host.startsWith('https://')) return host;
29
- return `https://${host}`;
30
- }
26
+ const { projectUrl: buildProjectUrl } = require('../config');
27
+ const projectUrl = (slug, customDomain) =>
28
+ buildProjectUrl(slug, customDomain) || '-';
31
29
 
32
30
  function copyIfExists(src, dest) {
33
31
  try {
@@ -49,26 +47,69 @@ function copyExamplesAndAssetsIntoProject(dir) {
49
47
  fs.mkdirSync(examplesDest, { recursive: true });
50
48
  }
51
49
 
52
- // Copy example .page files
50
+ // Collect full-page templates (exclude components-* demo files) for the starter pick.
51
+ // All examples are still copied into examples/ for the user to browse.
52
+ const allExampleFiles = [];
53
+ const starterCandidates = [];
53
54
  try {
54
55
  const files = fs.readdirSync(examplesSrc);
55
56
  for (const file of files) {
56
57
  if (!file.endsWith('.page')) continue;
57
- const src = path.join(examplesSrc, file);
58
- const dest = path.join(examplesDest, file);
59
- copyIfExists(src, dest);
58
+ allExampleFiles.push(file);
59
+ if (!file.startsWith('components-')) {
60
+ starterCandidates.push(file);
61
+ }
60
62
  }
61
63
  } catch {
62
64
  // ignore if templates/examples missing in this build
63
65
  }
64
66
 
65
- // Default logo / favicon in assets/ (uploaded on push as logo.svg / favicon.svg; examples use `logo: <- logo.svg`)
67
+ for (const file of allExampleFiles) {
68
+ const src = path.join(examplesSrc, file);
69
+ const dest = path.join(examplesDest, file);
70
+ copyIfExists(src, dest);
71
+ }
72
+
73
+ // Write a randomized starter landing.page at the project root.
74
+ if (starterCandidates.length > 0) {
75
+ const pick = starterCandidates[Math.floor(Math.random() * starterCandidates.length)];
76
+ copyIfExists(path.join(examplesSrc, pick), path.join(dir, 'landing.page'));
77
+ }
78
+
79
+ // Logo / favicon in assets/ — pick a random variant pair from logos/ and favicons/.
80
+ // Falls back to the root logo.svg / favicon.svg if the variant directories are absent or empty.
66
81
  const destAssets = path.join(dir, 'assets');
67
82
  if (!fs.existsSync(destAssets)) {
68
83
  fs.mkdirSync(destAssets, { recursive: true });
69
84
  }
70
- copyIfExists(path.join(assetsSrc, 'logo.svg'), path.join(destAssets, 'logo.svg'));
71
- copyIfExists(path.join(assetsSrc, 'favicon.svg'), path.join(destAssets, 'favicon.svg'));
85
+
86
+ const logosDir = path.join(assetsSrc, 'logos');
87
+ const faviconsDir = path.join(assetsSrc, 'favicons');
88
+ let usedVariant = false;
89
+
90
+ try {
91
+ if (fs.existsSync(logosDir)) {
92
+ const logoFiles = fs.readdirSync(logosDir).filter((f) => /^logo-[^.]+\.svg$/.test(f));
93
+ if (logoFiles.length > 0) {
94
+ const chosen = logoFiles[Math.floor(Math.random() * logoFiles.length)];
95
+ // logo-x.svg -> favicon-x.svg
96
+ const variantId = chosen.replace(/^logo-/, '').replace(/\.svg$/, '');
97
+ const matchingFavicon = path.join(faviconsDir, `favicon-${variantId}.svg`);
98
+ if (fs.existsSync(matchingFavicon)) {
99
+ copyIfExists(path.join(logosDir, chosen), path.join(destAssets, 'logo.svg'));
100
+ copyIfExists(matchingFavicon, path.join(destAssets, 'favicon.svg'));
101
+ usedVariant = true;
102
+ }
103
+ }
104
+ }
105
+ } catch {
106
+ // fall through to default below
107
+ }
108
+
109
+ if (!usedVariant) {
110
+ copyIfExists(path.join(assetsSrc, 'logo.svg'), path.join(destAssets, 'logo.svg'));
111
+ copyIfExists(path.join(assetsSrc, 'favicon.svg'), path.join(destAssets, 'favicon.svg'));
112
+ }
72
113
  }
73
114
 
74
115
  async function getLatestBuild(projectId) {
@@ -145,7 +186,7 @@ async function list(options = {}) {
145
186
 
146
187
  const rows = projects.map((p) => {
147
188
  const build = latestBuild[p.id];
148
- const url = projectUrl(p.custom_domain || p.domain);
189
+ const url = projectUrl(p.domain, p.custom_domain);
149
190
  return [
150
191
  p.uuid || '-',
151
192
  p.name || '-',
@@ -195,7 +236,7 @@ async function show(uuidOrDomain, options = {}) {
195
236
  return;
196
237
  }
197
238
 
198
- const url = projectUrl(project.custom_domain || project.domain);
239
+ const url = projectUrl(project.domain, project.custom_domain);
199
240
 
200
241
  console.log('UUID: ', project.uuid || '-');
201
242
  console.log('Name: ', project.name || '-');
@@ -261,9 +302,9 @@ async function create(name, options = {}) {
261
302
 
262
303
  console.log('Created project:', project.name || name);
263
304
  if (project.uuid) console.log('UUID:', project.uuid);
264
- console.log('URL:', projectUrl(project.domain));
305
+ console.log('URL:', projectUrl(project.domain, project.custom_domain));
265
306
  console.log('Folder:', dir);
266
- console.log(`\nNext: cd ${name} && touch landing.page && micropage push`);
307
+ console.log(`\nNext: cd ${name} && micropage push`);
267
308
  }
268
309
 
269
310
  // ---------------------------------------------------------------------------
package/src/config.js CHANGED
@@ -19,4 +19,30 @@ const BUILD_COMPILER_URL =
19
19
  process.env.MICROPAGE_PARSER_URL ||
20
20
  'https://build-compiler.micropage.sh';
21
21
 
22
- module.exports = { SUPABASE_URL, SUPABASE_ANON_KEY, APP_URL, BUILD_COMPILER_URL };
22
+ const BASE_DOMAIN = process.env.MICROPAGE_BASE_DOMAIN || 'micropage.sh';
23
+
24
+ // Live URL for a project. Prefers the customer's custom domain (a full FQDN
25
+ // like `www.example.com`) when set; falls back to `https://<slug>.<BASE_DOMAIN>`.
26
+ // Returns null when there's no usable identifier.
27
+ function projectUrl(slug, customDomain) {
28
+ const cd = customDomain && String(customDomain).trim();
29
+ if (cd) {
30
+ return cd.startsWith('http://') || cd.startsWith('https://')
31
+ ? cd
32
+ : `https://${cd}`;
33
+ }
34
+ if (!slug) return null;
35
+ const s = String(slug).trim();
36
+ if (!s) return null;
37
+ if (s.startsWith('http://') || s.startsWith('https://')) return s;
38
+ return `https://${s}.${BASE_DOMAIN}`;
39
+ }
40
+
41
+ module.exports = {
42
+ SUPABASE_URL,
43
+ SUPABASE_ANON_KEY,
44
+ APP_URL,
45
+ BUILD_COMPILER_URL,
46
+ BASE_DOMAIN,
47
+ projectUrl,
48
+ };
package/src/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  const { Command } = require('commander');
5
+ const { version } = require('../package.json');
5
6
 
6
7
  const login = require('./commands/login');
7
8
  const logout = require('./commands/logout');
@@ -18,7 +19,7 @@ const program = new Command();
18
19
  program
19
20
  .name('micropage')
20
21
  .description('CLI for micropage.sh – create, sync, and publish microsites')
21
- .version('2.0.2');
22
+ .version(version);
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Authentication
package/src/parser.js CHANGED
@@ -48,18 +48,15 @@ function writePageFile(dir, content) {
48
48
  return dest;
49
49
  }
50
50
 
51
- const ASSET_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.svg'];
52
-
53
51
  function listAssetsFromDir(dir) {
54
52
  const assetsDir = path.join(dir, 'assets');
55
53
  if (!fs.existsSync(assetsDir)) return [];
56
54
  const entries = fs.readdirSync(assetsDir, { withFileTypes: true });
57
55
  const files = [];
58
56
  for (const e of entries) {
59
- if (e.isFile()) {
60
- const ext = path.extname(e.name).toLowerCase();
61
- if (ASSET_EXTENSIONS.includes(ext)) files.push(e.name);
62
- }
57
+ if (!e.isFile()) continue;
58
+ if (e.name.startsWith('.')) continue;
59
+ files.push(e.name);
63
60
  }
64
61
  return files.sort();
65
62
  }
@@ -68,5 +65,4 @@ module.exports = {
68
65
  readPageFilesFromDir,
69
66
  writePageFile,
70
67
  listAssetsFromDir,
71
- ASSET_EXTENSIONS,
72
68
  };
package/src/supabase.js CHANGED
@@ -257,13 +257,23 @@ const db = {
257
257
  // ---------------------------------------------------------------------------
258
258
 
259
259
  async function getUserInfo(accessToken) {
260
- const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
261
- headers: {
262
- 'apikey': SUPABASE_ANON_KEY,
263
- 'Authorization': `Bearer ${accessToken}`,
264
- },
265
- });
266
- if (!res.ok) return null;
260
+ let res;
261
+ try {
262
+ res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
263
+ headers: {
264
+ 'apikey': SUPABASE_ANON_KEY,
265
+ 'Authorization': `Bearer ${accessToken}`,
266
+ },
267
+ });
268
+ } catch (err) {
269
+ console.error(`[micropage] getUserInfo network error: ${err.message}`);
270
+ return null;
271
+ }
272
+ if (!res.ok) {
273
+ const body = await res.text().catch(() => '');
274
+ console.error(`[micropage] getUserInfo failed (HTTP ${res.status} from ${SUPABASE_URL}/auth/v1/user): ${body}`);
275
+ return null;
276
+ }
267
277
  return res.json();
268
278
  }
269
279
 
@@ -427,6 +437,12 @@ async function streamDeployEventsUntilDone(bearerToken, projectId, buildId, opti
427
437
  return { terminalEvent };
428
438
  }
429
439
 
440
+ function hashFile(filePath) {
441
+ const crypto = require('crypto');
442
+ const fs = require('fs');
443
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
444
+ }
445
+
430
446
  /**
431
447
  * Upload a single asset file to the project via the upload-file Edge function.
432
448
  * Uses a pre-obtained Supabase access token.
@@ -437,10 +453,12 @@ async function uploadAssetWithToken(accessToken, projectId, filePath, filename)
437
453
 
438
454
  const bytes = fs.readFileSync(filePath);
439
455
  const mimeType = mime.fromFilename(filename);
456
+ const sha = hashFile(filePath);
440
457
 
441
458
  const formData = new FormData();
442
459
  formData.append('file', new Blob([bytes], { type: mimeType }), filename);
443
460
  formData.append('project_id', String(projectId));
461
+ formData.append('content_hash', sha);
444
462
 
445
463
  const res = await fetch(`${SUPABASE_URL}/functions/v1/upload-file`, {
446
464
  method: 'POST',
@@ -463,9 +481,33 @@ async function uploadAssetWithToken(accessToken, projectId, filePath, filename)
463
481
  return data;
464
482
  }
465
483
 
484
+ async function deleteFileWithToken(accessToken, fileId) {
485
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/delete-file`, {
486
+ method: 'POST',
487
+ headers: {
488
+ 'apikey': SUPABASE_ANON_KEY,
489
+ 'Authorization': `Bearer ${accessToken}`,
490
+ 'Content-Type': 'application/json',
491
+ },
492
+ body: JSON.stringify({ file_id: fileId }),
493
+ });
494
+ const text = await res.text();
495
+ let data = null;
496
+ try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; }
497
+ if (!res.ok) {
498
+ const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`;
499
+ const err = new Error(msg);
500
+ err.status = res.status;
501
+ throw err;
502
+ }
503
+ return data;
504
+ }
505
+
466
506
  /**
467
507
  * Upload all assets from the local `assets/` directory for a project.
468
- * Skips files that already exist on the server (matched by filename).
508
+ * Compares each local file's SHA-256 hash against the server's stored hash.
509
+ * Skips files whose content is unchanged. Deletes and re-uploads files whose
510
+ * content has changed (or whose server hash is NULL from before hashing was added).
469
511
  * Returns the count of files uploaded.
470
512
  */
471
513
  async function uploadAssetsWithToken(accessToken, projectId, cwd, onProgress) {
@@ -475,7 +517,9 @@ async function uploadAssetsWithToken(accessToken, projectId, cwd, onProgress) {
475
517
  const assetFiles = listAssetsFromDir(cwd);
476
518
  if (assetFiles.length === 0) return 0;
477
519
 
478
- // Fetch existing filenames so we can skip unchanged files
520
+ // Fetch existing files so we can skip unchanged ones. We MUST treat a
521
+ // non-OK response as fatal: continuing with an empty list would re-upload
522
+ // every asset and (when filenames collide) leave duplicate user_files rows.
479
523
  const listRes = await fetch(
480
524
  `${SUPABASE_URL}/functions/v1/list-files?project_id=${encodeURIComponent(projectId)}`,
481
525
  {
@@ -485,13 +529,28 @@ async function uploadAssetsWithToken(accessToken, projectId, cwd, onProgress) {
485
529
  },
486
530
  },
487
531
  );
488
- const listData = listRes.ok ? await listRes.json() : null;
489
- const existing = new Set((listData?.files || []).map((f) => f.filename));
532
+ if (!listRes.ok) {
533
+ const text = await listRes.text().catch(() => '');
534
+ const err = new Error(`Failed to list project files: HTTP ${listRes.status}${text ? ` — ${text}` : ''}`);
535
+ err.status = listRes.status;
536
+ throw err;
537
+ }
538
+ const listData = await listRes.json();
539
+ const existingByName = new Map(
540
+ (listData?.files || []).map((f) => [f.filename, { id: f.id, content_hash: f.content_hash }]),
541
+ );
490
542
 
491
543
  let uploaded = 0;
492
544
  for (const filename of assetFiles) {
493
- if (existing.has(filename)) continue;
494
545
  const filePath = path.join(cwd, 'assets', filename);
546
+ const localHash = hashFile(filePath);
547
+ const existing = existingByName.get(filename);
548
+
549
+ if (existing && existing.content_hash === localHash) continue;
550
+
551
+ if (existing) {
552
+ await deleteFileWithToken(accessToken, existing.id);
553
+ }
495
554
  await uploadAssetWithToken(accessToken, projectId, filePath, filename);
496
555
  if (onProgress) onProgress(filename);
497
556
  uploaded++;
@@ -634,4 +693,6 @@ module.exports = {
634
693
  exchangeDeployTokenForAccessToken,
635
694
  pushWithToken,
636
695
  invokePublishBuild,
696
+ uploadAssetsWithToken,
697
+ hashFile,
637
698
  };
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-labelledby="title desc">
2
+ <title>Micropage favicon — variant A</title>
3
+ <desc>Minimal stacked bar favicon for starter projects</desc>
4
+ <rect x="4" y="7" width="24" height="4" rx="2" fill="#4f46e5"/>
5
+ <rect x="6" y="14" width="20" height="4" rx="2" fill="#0ea5e9"/>
6
+ <rect x="8" y="21" width="16" height="4" rx="2" fill="#22c55e"/>
7
+ </svg>
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-labelledby="title desc">
2
+ <title>Micropage favicon — variant B</title>
3
+ <desc>Minimal sunrise arc favicon</desc>
4
+ <defs>
5
+ <linearGradient id="g" x1="0%" y1="100%" x2="100%" y2="0%">
6
+ <stop offset="0%" stop-color="#dc2626"/>
7
+ <stop offset="100%" stop-color="#f59e0b"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <path d="M 4 22 A 12 12 0 0 1 28 22" stroke="url(#g)" stroke-width="5" fill="none" stroke-linecap="round"/>
11
+ <circle cx="16" cy="22" r="3" fill="#dc2626"/>
12
+ </svg>
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-labelledby="title desc">
2
+ <title>Micropage favicon — variant C</title>
3
+ <desc>Minimal concentric circles favicon in forest green</desc>
4
+ <circle cx="16" cy="14" r="10" fill="#166534"/>
5
+ <circle cx="16" cy="14" r="5" fill="#14532d"/>
6
+ <circle cx="16" cy="14" r="2" fill="#bbf7d0"/>
7
+ <rect x="14" y="24" width="4" height="6" rx="2" fill="#713f12"/>
8
+ </svg>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-labelledby="title desc">
2
+ <title>Micropage favicon — variant D</title>
3
+ <desc>Minimal diamond favicon in charcoal with magenta center</desc>
4
+ <polygon points="16,4 28,16 16,28 4,16" fill="#1c1c1c"/>
5
+ <polygon points="16,11 21,16 16,21 11,16" fill="#e91e8c"/>
6
+ </svg>
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
2
+ <title>Micropage starter logo — variant A</title>
3
+ <desc>Abstract stacked rectangles suggesting pages</desc>
4
+ <defs>
5
+ <linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
6
+ <stop offset="0%" stop-color="#4f46e5"/>
7
+ <stop offset="100%" stop-color="#06b6d4"/>
8
+ </linearGradient>
9
+ </defs>
10
+ <rect x="10" y="14" width="44" height="12" rx="3" fill="url(#g)"/>
11
+ <rect x="14" y="28" width="36" height="10" rx="3" fill="#111827" opacity="0.9"/>
12
+ <rect x="18" y="40" width="28" height="8" rx="3" fill="#111827" opacity="0.7"/>
13
+ </svg>
@@ -0,0 +1,16 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
2
+ <title>Micropage starter logo — variant B</title>
3
+ <desc>Abstract sunrise arc suggesting warmth and momentum</desc>
4
+ <defs>
5
+ <linearGradient id="g" x1="0%" y1="100%" x2="100%" y2="0%">
6
+ <stop offset="0%" stop-color="#dc2626"/>
7
+ <stop offset="50%" stop-color="#ea580c"/>
8
+ <stop offset="100%" stop-color="#f59e0b"/>
9
+ </linearGradient>
10
+ </defs>
11
+ <path d="M 8 44 A 24 24 0 0 1 56 44" stroke="url(#g)" stroke-width="8" fill="none" stroke-linecap="round"/>
12
+ <circle cx="32" cy="44" r="5" fill="#dc2626"/>
13
+ <line x1="32" y1="14" x2="32" y2="22" stroke="#f59e0b" stroke-width="4" stroke-linecap="round"/>
14
+ <line x1="14" y1="26" x2="20" y2="30" stroke="#ea580c" stroke-width="3" stroke-linecap="round"/>
15
+ <line x1="50" y1="26" x2="44" y2="30" stroke="#ea580c" stroke-width="3" stroke-linecap="round"/>
16
+ </svg>
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
2
+ <title>Micropage starter logo — variant C</title>
3
+ <desc>Abstract leaf and root mark suggesting growth and earth</desc>
4
+ <circle cx="32" cy="20" r="14" fill="#166534"/>
5
+ <circle cx="32" cy="20" r="8" fill="#14532d"/>
6
+ <circle cx="32" cy="20" r="4" fill="#bbf7d0"/>
7
+ <rect x="30" y="34" width="4" height="14" rx="2" fill="#713f12"/>
8
+ <path d="M 20 48 Q 32 38 44 48" stroke="#713f12" stroke-width="3" fill="none" stroke-linecap="round"/>
9
+ </svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title desc">
2
+ <title>Micropage starter logo — variant D</title>
3
+ <desc>Bold geometric diamond mark in charcoal with magenta accent</desc>
4
+ <polygon points="32,8 52,32 32,56 12,32" fill="#1c1c1c"/>
5
+ <polygon points="32,18 44,32 32,46 20,32" fill="#2e2e2e"/>
6
+ <polygon points="32,26 38,32 32,38 26,32" fill="#e91e8c"/>
7
+ </svg>
@@ -0,0 +1,130 @@
1
+ [site]
2
+ title: Slab Studio
3
+ description: Brand, digital, and motion design for companies that want to stand out
4
+ logo: <- logo.svg
5
+ favicon: <- favicon.svg
6
+ colors:
7
+ primary: #111111
8
+ secondary: #333333
9
+ danger: #ff6b35
10
+
11
+ [site.dark]
12
+ colors:
13
+ primary: #f5f5f5
14
+ secondary: #cccccc
15
+
16
+ [nav]
17
+ Work -> /
18
+ Studio -> /studio
19
+ Contact -> /contact
20
+
21
+ [footer]
22
+ /// section bg:dark text:light
23
+
24
+ col:
25
+ h5: SLAB STUDIO
26
+ p: Brand — Digital — Motion
27
+ p: Brooklyn, New York
28
+
29
+ col:
30
+ link: LinkedIn -> https://linkedin.com
31
+ link: Dribbble -> https://dribbble.com
32
+ link: Instagram -> https://instagram.com
33
+
34
+ col:
35
+ p: New projects: hello@slabstudio.co
36
+ p: Press: press@slabstudio.co
37
+
38
+
39
+ [Work -> /]
40
+
41
+ /// section align:center
42
+ h1: We make things look like they mean it.
43
+ p: Slab Studio is a seven-person brand and digital studio. We work with founders and creative directors who know what they want and need a team that can build it.
44
+
45
+ /// section
46
+ h2: Selected projects
47
+
48
+ col:
49
+ h3: Cinder — Brand Identity
50
+ p: End-to-end identity for a B2B fintech: wordmark, color system, motion guidelines, and a component library used across three product surfaces.
51
+ small: Fintech · 2024
52
+
53
+ col:
54
+ h3: Waypoint — Campaign
55
+ p: Product launch campaign for a logistics SaaS — visual language, video direction, and a microsite that converted at 4.2%.
56
+ small: Logistics · 2024
57
+
58
+ col:
59
+ h3: Olive & Thread — Packaging
60
+ p: Rebranded a sustainable fashion label from logo to hangtag to e-commerce photography direction.
61
+ small: Fashion · 2023
62
+
63
+ /// section bg:muted
64
+ h2: More work
65
+
66
+ col:
67
+ h3: Nova Health — Digital Product
68
+ p: Design system and patient-facing app UI for a telehealth platform. Accessibility-first, WCAG 2.1 AA.
69
+ small: Health tech · 2023
70
+
71
+ col:
72
+ h3: Paloma Records — Art Direction
73
+ p: Album artwork, lyric video direction, and social templates for four releases.
74
+ small: Music · 2023
75
+
76
+ col:
77
+ h3: Fieldwork Brewing — Web
78
+ p: Responsive marketing site with custom animations, tap room finder, and e-commerce integration.
79
+ small: Food & beverage · 2022
80
+
81
+
82
+ [Studio -> /studio]
83
+
84
+ /// section
85
+ h1: About Slab
86
+
87
+ col:
88
+ p: We started Slab in 2018 after leaving larger agencies because we wanted to do fewer projects, better. Seven people, no junior-to-senior ratio games, no account managers between you and the work.
89
+
90
+ col:
91
+ p: Everyone at Slab touches client work. Our lead designer also codes. Our strategist also writes. That's on purpose.
92
+
93
+ /// section align:center
94
+ h2: How we work
95
+
96
+ col:
97
+ icon: bi bi-chat-square-text
98
+ h3: Discovery
99
+ p: Two-week engagement to align on objectives, audiences, and success criteria before any creative begins.
100
+
101
+ col:
102
+ icon: bi bi-easel
103
+ h3: Creative development
104
+ p: Three rounds of feedback with clear decision points. No endless-revision contracts.
105
+
106
+ col:
107
+ icon: bi bi-box-arrow-up-right
108
+ h3: Handoff
109
+ p: Full source files, a brand guidelines document, and a 30-day support window after launch.
110
+
111
+ /// section bg:dark text:light align:center
112
+ h2: We work with clients who ship.
113
+ p: Not committees. Not approvals-by-committee. Founders, CPOs, and creative directors with the authority to make decisions and the taste to know a good one.
114
+ button: Start a conversation -> /contact
115
+
116
+
117
+ [Contact -> /contact]
118
+
119
+ /// section
120
+ h1: Start a project
121
+ p: Tell us what you're building and why it matters. We'll come back within 48 hours.
122
+
123
+ form: intake
124
+ input: Name*
125
+ input: Email*
126
+ input: Company
127
+ select: Project type [Brand identity, Web design, Campaign, Motion, Product design, Other]
128
+ select: Approximate budget [$20k–$50k, $50k–$100k, $100k+, Not sure yet]
129
+ text: Tell us about the project*
130
+ submit: Send brief
@@ -0,0 +1,158 @@
1
+ [site]
2
+ title: The Long Read
3
+ description: Reporting and essays on technology, cities, and the people who build them
4
+ logo: <- logo.svg
5
+ favicon: <- favicon.svg
6
+ colors:
7
+ primary: #1f2937
8
+ secondary: #374151
9
+ danger: #b91c1c
10
+
11
+ [site.dark]
12
+ colors:
13
+ primary: #f9fafb
14
+ secondary: #e5e7eb
15
+ danger: #ef4444
16
+
17
+ [nav]
18
+ Latest -> /
19
+ Archive -> /archive
20
+ About -> /about
21
+ Subscribe -> /subscribe
22
+
23
+ [footer]
24
+ /// section
25
+
26
+ col:
27
+ h5: THE LONG READ
28
+ p: Published weekly. No ads. Reader-supported.
29
+
30
+ col:
31
+ link: Twitter -> https://twitter.com
32
+ link: RSS -> /feed.xml
33
+ link: Archive -> /archive
34
+
35
+ col:
36
+ p: Tips and story ideas: tips@thelongread.io
37
+ p: Licensing: rights@thelongread.io
38
+
39
+
40
+ [Latest -> /]
41
+
42
+ /// section
43
+ h1: Latest stories
44
+
45
+ col:
46
+ h3: The Fiber Wars
47
+ p: Why three companies are spending $80 billion to wire the same neighborhoods — and who loses when they all pull back.
48
+ small: Infrastructure · 22 min read
49
+
50
+ col:
51
+ h3: What Happened to the Promise of Smart Cities
52
+ p: A decade ago, Sidewalk Toronto was supposed to prove that data could make urban life better. Here's what we learned from its failure.
53
+ small: Cities · 18 min read
54
+
55
+ col:
56
+ h3: The Engineer Who Said No
57
+ p: When Priya Nair refused to ship a feature she thought was dangerous, she lost her job. Two years later, she's not sure she'd do it differently.
58
+ small: Profile · 14 min read
59
+
60
+ /// section bg:muted
61
+ h2: From the archive
62
+
63
+ col:
64
+ h3: The Last Payphone
65
+ p: New York's final payphone came down in May 2022. We spent a day with the crew that removed it.
66
+ small: Cities · 8 min read
67
+
68
+ col:
69
+ h3: Chasing the Algorithm
70
+ p: A content moderation contractor describes 18 months of reading the internet's worst content so you don't have to.
71
+ small: Technology · 26 min read
72
+
73
+ col:
74
+ h3: Where Did the Mid-Size City Go?
75
+ p: Population data tells one story. Walking around Youngstown, Ohio tells another.
76
+ small: Cities · 15 min read
77
+
78
+ /// section bg:danger text:light align:center
79
+ h2: Become a supporting reader.
80
+ p: The Long Read is fully reader-supported. No investors, no brand deals, no tracking. A subscription covers our editorial costs and keeps the site ad-free.
81
+ button: Subscribe — $10/month -> /subscribe
82
+ btn-secondary: Gift a subscription -> /gift
83
+
84
+
85
+ [Archive -> /archive]
86
+
87
+ /// section
88
+ h1: All stories
89
+
90
+ /// section
91
+ h2: Technology
92
+
93
+ col:
94
+ link: The Fiber Wars -> /stories/fiber-wars
95
+ link: Chasing the Algorithm -> /stories/chasing-algorithm
96
+ link: Who Owns the Sidewalk Data? -> /stories/sidewalk-data
97
+ link: The Quiet Monopoly -> /stories/quiet-monopoly
98
+
99
+ /// section
100
+ h2: Cities
101
+
102
+ col:
103
+ link: What Happened to the Promise of Smart Cities -> /stories/smart-cities
104
+ link: The Last Payphone -> /stories/last-payphone
105
+ link: Where Did the Mid-Size City Go? -> /stories/mid-size-city
106
+ link: Living on the Heat Map -> /stories/heat-map
107
+
108
+
109
+ [About -> /about]
110
+
111
+ /// section
112
+ h1: About The Long Read
113
+
114
+ col:
115
+ p: The Long Read was started in 2021 by two journalists who were tired of writing to SEO briefs. We publish one to two pieces a week — long-form reported stories and essays about technology and the built environment.
116
+
117
+ col:
118
+ p: We have no investors, no editorial board, and no algorithmic pressures. If something takes six months to report properly, we take six months.
119
+
120
+ /// section align:center
121
+ h2: Our editorial standards
122
+
123
+ col:
124
+ icon: bi bi-incognito
125
+ h3: No tracking
126
+ p: We collect no analytics, no behavioral data, no ad pixels. Your reading is your own.
127
+
128
+ col:
129
+ icon: bi bi-journal-check
130
+ h3: Named sources
131
+ p: We don't publish anonymously sourced claims without a documented reason. We always try to give subjects the chance to respond.
132
+
133
+ col:
134
+ icon: bi bi-archive
135
+ h3: Everything stays up
136
+ p: We don't delete stories. If we get something wrong, we publish a correction and leave both up.
137
+
138
+
139
+ [Subscribe -> /subscribe]
140
+
141
+ /// section
142
+ h1: Support independent journalism
143
+ p: Subscribers get all stories in their inbox each week, access to the full archive, and the knowledge that this publication exists because of them.
144
+
145
+ col:
146
+ h3: Monthly
147
+ p: $10 per month. Cancel any time.
148
+ button: Subscribe monthly -> /checkout/monthly
149
+
150
+ col:
151
+ h3: Annual
152
+ p: $90 per year — save $30.
153
+ button: Subscribe annually -> /checkout/annual
154
+
155
+ col:
156
+ h3: Founding reader
157
+ p: $200 per year. Your name in our annual supporters list.
158
+ button: Become a founding reader -> /checkout/founding
@@ -0,0 +1,181 @@
1
+ [site]
2
+ title: Fluxline
3
+ description: Open-source event streaming SDK for TypeScript and Go
4
+ logo: <- logo.svg
5
+ favicon: <- favicon.svg
6
+ colors:
7
+ primary: #0d9488
8
+ secondary: #0f766e
9
+ info: #0891b2
10
+
11
+ [site.dark]
12
+ colors:
13
+ primary: #2dd4bf
14
+ secondary: #14b8a6
15
+
16
+ [nav]
17
+ Home -> /
18
+ Docs -> /docs
19
+ Pricing -> /pricing
20
+ GitHub -> https://github.com
21
+
22
+ [footer]
23
+ /// section bg:dark text:light
24
+
25
+ col:
26
+ h5: FLUXLINE
27
+ p: Open source under Apache 2.0
28
+ link: GitHub -> https://github.com
29
+
30
+ col:
31
+ link: Documentation -> /docs
32
+ link: Changelog -> /changelog
33
+ link: Status -> https://status.fluxline.dev
34
+
35
+ col:
36
+ p: Fluxline is maintained by a small team and community contributors.
37
+ link: Contributing guide -> /contributing
38
+
39
+
40
+ [Home -> /]
41
+
42
+ /// section bg:dark text:light
43
+ h5: OPEN SOURCE · TYPESCRIPT · GO
44
+ h1: Stream events without the boilerplate.
45
+ p: Fluxline is a lightweight SDK for publishing, subscribing to, and replaying ordered event streams. Works with your existing message broker — no new infrastructure required.
46
+
47
+ ////
48
+ html: <pre><code>import { Fluxline } from 'fluxline'
49
+
50
+ const client = new Fluxline({ brokerUrl: process.env.BROKER_URL })
51
+ await client.publish('orders', { id: 'ord_01', total: 49.00 })</code></pre>
52
+
53
+ col:
54
+ button: Get started -> /docs
55
+ btn-secondary: View on GitHub -> https://github.com
56
+
57
+ col:
58
+ p: v1.4.2 · 2.1 kB gzipped · Zero runtime dependencies
59
+
60
+ /// section
61
+ h2: Why Fluxline
62
+
63
+ col:
64
+ icon: bi bi-lightning-charge
65
+ h3: Under 3 kB
66
+ p: The TypeScript client ships at 2.1 kB gzipped. The Go client is a single file you can vendor.
67
+
68
+ col:
69
+ icon: bi bi-arrow-repeat
70
+ h3: Replay from any offset
71
+ p: Seek back to any sequence number and replay events forward. Works with Kafka, NATS, and Redis Streams.
72
+
73
+ col:
74
+ icon: bi bi-shield-check
75
+ h3: Exactly-once delivery
76
+ p: Idempotent producer IDs and consumer group offsets give you strong delivery guarantees out of the box.
77
+
78
+ /// section bg:primary-subtle
79
+ h2: Framework integrations
80
+
81
+ col:
82
+ h3: Next.js / Remix
83
+ p: Server action helpers and route handler bindings. Automatically infer event types from your Zod schemas.
84
+
85
+ col:
86
+ h3: Hono & Fastify
87
+ p: Middleware that turns any route into an event source. Streaming responses with backpressure handling.
88
+
89
+ col:
90
+ h3: Go net/http
91
+ p: Handler adapter for standard Go HTTP servers. No generics gymnastics — just plain functions.
92
+
93
+
94
+ [Docs -> /docs]
95
+
96
+ /// section
97
+ h1: Getting started
98
+
99
+ /// section bg:muted
100
+ h2: Installation
101
+
102
+ ////
103
+ html: <pre><code># TypeScript / Node
104
+ npm install fluxline
105
+
106
+ # Go
107
+ go get github.com/fluxline/fluxline-go</code></pre>
108
+
109
+ /// section
110
+ h2: Quick start
111
+
112
+ col:
113
+ h3: 1. Create a client
114
+ p: Initialize with your broker URL and an optional namespace prefix. Credentials are read from environment variables by default.
115
+
116
+ col:
117
+ h3: 2. Define your events
118
+ p: Use the TypeScript generic or Go struct tag to attach a schema. Fluxline validates before publishing and narrows the type on receive.
119
+
120
+ col:
121
+ h3: 3. Publish and subscribe
122
+ p: publish() is fire-and-forget with an optional ack callback. subscribe() returns an async iterator or a channel, depending on the language.
123
+
124
+ /// section
125
+ h2: Configuration reference
126
+
127
+ col:
128
+ h3: brokerUrl
129
+ p: Required. WebSocket or TCP URL for your message broker.
130
+
131
+ col:
132
+ h3: namespace
133
+ p: Optional string prefix applied to all stream names. Useful for multi-tenant deployments.
134
+
135
+ col:
136
+ h3: maxRetries
137
+ p: Default 3. How many times to retry a failed publish before raising an error.
138
+
139
+ col:
140
+ h3: ackTimeout
141
+ p: Default 5000 ms. Time to wait for a broker ack before treating the publish as failed.
142
+
143
+
144
+ [Pricing -> /pricing]
145
+
146
+ /// section align:center
147
+ h1: Free to start. Pay as you grow.
148
+ p: All plans include the full SDK feature set. Pricing is based on monthly event volume.
149
+
150
+ col:
151
+ h3: Open Source
152
+ p: Self-hosted, unlimited events, community support.
153
+ p: Always free.
154
+ button: Get started -> /docs
155
+
156
+ col:
157
+ h3: Cloud
158
+ p: Managed broker, 10M events/month included.
159
+ p: $49 / month
160
+ button: Start free trial -> /trial
161
+
162
+ col:
163
+ h3: Enterprise
164
+ p: Dedicated cluster, SLA, audit logs, SSO, custom retention.
165
+ p: Custom pricing
166
+ button: Talk to us -> /contact
167
+
168
+ /// section bg:muted
169
+ h2: Frequently asked questions
170
+
171
+ col:
172
+ h3: Can I use my own Kafka cluster?
173
+ p: Yes. The SDK is broker-agnostic. The managed Cloud tier runs NATS JetStream under the hood, but the OSS version works with any NATS, Kafka, or Redis Streams backend.
174
+
175
+ col:
176
+ h3: Is there a free trial for Cloud?
177
+ p: Cloud plans include a 14-day trial with no credit card required. You'll get 10M events to test with before you're charged.
178
+
179
+ col:
180
+ h3: What counts as an event?
181
+ p: Any publish() call that reaches the broker. Failed attempts before retry exhaustion are not counted.
@@ -0,0 +1,171 @@
1
+ [site]
2
+ title: Frontline Summit
3
+ description: The annual conference for product, design, and engineering leaders
4
+ logo: <- logo.svg
5
+ favicon: <- favicon.svg
6
+ colors:
7
+ primary: #e3236d
8
+ secondary: #b01a55
9
+ info: #7c3aed
10
+
11
+ [site.dark]
12
+ colors:
13
+ primary: #f0457e
14
+ secondary: #e3236d
15
+
16
+ [nav]
17
+ Home -> /
18
+ Schedule -> /schedule
19
+ Speakers -> /speakers
20
+ Register -> /register
21
+
22
+ [footer]
23
+ /// section bg:dark text:light
24
+
25
+ col:
26
+ h5: FRONTLINE SUMMIT 2025
27
+ p: March 12–14, 2025
28
+ p: Chicago, IL — The Civic Ballroom
29
+
30
+ col:
31
+ link: Code of conduct -> /coc
32
+ link: Sponsorship -> /sponsors
33
+ link: Contact -> /contact
34
+
35
+ col:
36
+ p: Questions? Email us at hello@frontlinesummit.com
37
+
38
+
39
+ [Home -> /]
40
+
41
+ /// hero bg:primary text:light
42
+ h1: Build what comes next.
43
+ p: Frontline Summit brings together 1,200 product, design, and engineering leaders for three days of talks, workshops, and honest conversations about how software gets made.
44
+ p: March 12–14, 2025 · Chicago
45
+ button: Register now -> /register
46
+ btn-secondary: View schedule -> /schedule
47
+
48
+ /// section
49
+ h2: This year's tracks
50
+
51
+ col:
52
+ icon: bi bi-cpu
53
+ h3: Platform Engineering
54
+ p: Developer experience, internal platforms, and the golden path between speed and stability.
55
+
56
+ col:
57
+ icon: bi bi-palette
58
+ h3: Product Design at Scale
59
+ p: Design systems, accessibility debt, and leading creative work in a fast-moving org.
60
+
61
+ col:
62
+ icon: bi bi-people
63
+ h3: Leadership & Culture
64
+ p: Hiring, retention, psychological safety, and building teams that survive hypergrowth.
65
+
66
+ /// section bg:muted
67
+ h2: Formats
68
+
69
+ col:
70
+ h3: Keynotes
71
+ p: Six 45-minute talks from practitioners who've shipped things that mattered.
72
+
73
+ col:
74
+ h3: Deep-dive workshops
75
+ p: Half-day sessions capped at 30 attendees. Hands-on, no slides, all practice.
76
+
77
+ col:
78
+ h3: Open hallway track
79
+ p: Unscheduled space for spontaneous conversations. Some of the best talks happen here.
80
+
81
+
82
+ [Schedule -> /schedule]
83
+
84
+ /// section
85
+ h1: Schedule at a glance
86
+
87
+ /// section bg:primary-subtle
88
+ h2: Day 1 — March 12
89
+
90
+ col:
91
+ h3: 9:00 am — Opening keynote
92
+ p: "The Decade of the Platform" — keynote by Mira Okonkwo, VP Engineering at Lattice.
93
+
94
+ col:
95
+ h3: 11:00 am — Workshop block A
96
+ p: Choose from: Incident culture, Design sprints for complex features, Staff eng career paths.
97
+
98
+ col:
99
+ h3: 2:00 pm — Lightning talks
100
+ p: Eight 10-minute talks from speakers selected from the open CFP.
101
+
102
+ /// section
103
+ h2: Day 2 — March 13
104
+
105
+ col:
106
+ h3: 9:30 am — Keynote
107
+ p: "Shipping with Confidence" — how Vercel restructured their deploy pipeline and reduced rollbacks by 60%.
108
+
109
+ col:
110
+ h3: 1:00 pm — Panel
111
+ p: "AI in the dev workflow: what's actually working" — four engineering leaders, unfiltered.
112
+
113
+ col:
114
+ h3: 4:00 pm — Unconference
115
+ p: Participant-led sessions. Vote on topics in the morning, run sessions in the afternoon.
116
+
117
+
118
+ [Speakers -> /speakers]
119
+
120
+ /// section align:center
121
+ h1: Featured speakers
122
+
123
+ col:
124
+ h3: Mira Okonkwo
125
+ p: VP Engineering, Lattice
126
+ p: Scaling platform teams from 12 to 120 engineers without losing the culture.
127
+
128
+ col:
129
+ h3: Dev Patel
130
+ p: Staff Engineer, Linear
131
+ p: Real-time sync architecture and the tradeoffs nobody talks about.
132
+
133
+ col:
134
+ h3: Carmen Estrada
135
+ p: Head of Design, Figma
136
+ p: Building a design system that 4,000 designers actually use.
137
+
138
+ /// section
139
+ h2: Call for proposals
140
+
141
+ col:
142
+ p: We select 30% of our program from open CFP submissions. If you've solved a hard problem and want to share it, we want to hear from you.
143
+ button: Submit a proposal -> /cfp
144
+
145
+ col:
146
+ p: CFP closes January 15, 2025. Decisions sent by February 1. Travel and hotel covered for selected speakers.
147
+
148
+
149
+ [Register -> /register]
150
+
151
+ /// section
152
+ h1: Get your ticket
153
+ p: Early-bird pricing ends December 1. Workshop seats are limited and sell out first.
154
+
155
+ col:
156
+ h3: Conference pass
157
+ p: Full three-day access, all keynotes and lightning talks, hallway track.
158
+ p: $799 early bird / $999 standard
159
+ button: Register -> /checkout
160
+
161
+ col:
162
+ h3: Conference + Workshop
163
+ p: Everything in the conference pass plus one half-day workshop of your choice.
164
+ p: $1,199 early bird / $1,499 standard
165
+ button: Register -> /checkout
166
+
167
+ col:
168
+ h3: Team of 5
169
+ p: Five conference passes at a 20% discount. Invoice billing available.
170
+ p: $3,196 (saves $800)
171
+ button: Contact us -> /contact
@@ -0,0 +1,124 @@
1
+ [site]
2
+ title: Ember & Salt
3
+ description: Wood-fired kitchen and craft cocktail bar in the heart of the city
4
+ logo: <- logo.svg
5
+ favicon: <- favicon.svg
6
+ colors:
7
+ primary: #c1432d
8
+ secondary: #8b2c17
9
+ warning: #e8a02a
10
+
11
+ [site.dark]
12
+ colors:
13
+ primary: #e05a40
14
+ secondary: #c1432d
15
+
16
+ [nav]
17
+ Menu -> /
18
+ About -> /about
19
+ Reservations -> /reservations
20
+
21
+ [footer]
22
+ /// section bg:dark text:light
23
+
24
+ col:
25
+ h5: EMBER & SALT
26
+ p: 142 North Market Street
27
+ p: Open Tuesday–Sunday, 5 pm – 11 pm
28
+
29
+ col:
30
+ link: Instagram -> https://instagram.com
31
+ link: Yelp -> https://yelp.com
32
+ link: Google Maps -> https://maps.google.com
33
+
34
+ col:
35
+ p: Reservations recommended on weekends.
36
+ link: Book a table -> /reservations
37
+
38
+
39
+ [Menu -> /]
40
+
41
+ /// hero bg:dark text:light
42
+ h1: Fire. Smoke. Salt.
43
+ p: We cook over an open wood hearth. Everything is made in-house — from the pasta to the pickles to the ice cream.
44
+ button: Book a table -> /reservations
45
+ btn-secondary: View full menu -> /menu
46
+
47
+ /// section
48
+ h2: From the kitchen
49
+
50
+ col:
51
+ h3: Burrata & Heirloom Tomato
52
+ p: Stone-fruit vinaigrette, basil oil, toasted hazelnuts. Seasonal.
53
+ small: Starter — $18
54
+
55
+ col:
56
+ h3: Hand-Rolled Pappardelle
57
+ p: Braised short rib, rosemary jus, Parmigiano Reggiano.
58
+ small: Pasta — $32
59
+
60
+ col:
61
+ h3: Wood-Fired Half Chicken
62
+ p: Spatchcocked over oak, herb butter, roasted garlic aioli, market greens.
63
+ small: Main — $38
64
+
65
+ /// section bg:primary-subtle
66
+ h2: Signature cocktails
67
+
68
+ col:
69
+ h3: Ember Old Fashioned
70
+ p: Smoked rye, demerara, orange peel, Angostura.
71
+
72
+ col:
73
+ h3: Salt & Citrus Sour
74
+ p: Mezcal, yuzu, egg white, black lava salt rim.
75
+
76
+ col:
77
+ h3: Garden Mule
78
+ p: House ginger beer, cucumber vodka, fresh mint, lime.
79
+
80
+
81
+ [About -> /about]
82
+
83
+ /// section align:center
84
+ h1: A kitchen built on fire
85
+
86
+ col:
87
+ p: Ember & Salt started in 2019 as a pop-up at the Fulton Farmers Market. We outgrew our tent. Now we have a wood-burning hearth, a walk-in full of heritage-breed pork and local vegetables, and a bar that takes its bitters seriously.
88
+
89
+ col:
90
+ p: Chef Rena Kowalski spent twelve years in Lyon before returning to open the restaurant she always wanted: unpretentious, ingredient-driven, and stubbornly seasonal.
91
+
92
+ /// section
93
+ h2: Awards & press
94
+
95
+ col:
96
+ icon: bi bi-award
97
+ h3: Best New Restaurant
98
+ p: City & State Dining Guide, 2022
99
+
100
+ col:
101
+ icon: bi bi-newspaper
102
+ h3: Featured in Eater
103
+ p: "One of the most exciting wood-fire openings of the year."
104
+
105
+ col:
106
+ icon: bi bi-star
107
+ h3: Michelin Bib Gourmand
108
+ p: Recognized for exceptional value and quality, 2023 guide.
109
+
110
+
111
+ [Reservations -> /reservations]
112
+
113
+ /// section
114
+ h1: Reserve a table
115
+ p: We hold tables up to 48 hours in advance. Walk-ins welcome at the bar.
116
+
117
+ form: reservation
118
+ input: Full name*
119
+ input: Email*
120
+ input: Phone
121
+ select: Party size [1, 2, 3–4, 5–6, 7+]
122
+ select: Preferred time [5:00 pm, 6:00 pm, 7:00 pm, 8:00 pm, 9:00 pm]
123
+ text: Special requests or dietary needs
124
+ submit: Request reservation