@lettercamp/cli 0.1.7 → 0.1.9

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.
Files changed (60) hide show
  1. package/dist/account.js +20 -8
  2. package/dist/build.js +22 -5
  3. package/dist/cli.js +15 -6
  4. package/dist/code-block.js +50 -0
  5. package/dist/code-detection.js +24 -0
  6. package/dist/code-highlight.js +54 -0
  7. package/dist/component-blocks.js +32 -0
  8. package/dist/custom-layout.js +32 -0
  9. package/dist/deploy.js +131 -0
  10. package/dist/dev.js +28 -10
  11. package/dist/domain.js +190 -0
  12. package/dist/hosting.js +132 -0
  13. package/dist/lettercamp.js +1 -4
  14. package/dist/media-dimensions.js +154 -0
  15. package/dist/new.js +1 -1
  16. package/dist/preview.js +1 -1
  17. package/dist/prompts.js +31 -0
  18. package/dist/publication-links.js +23 -0
  19. package/dist/react.js +15 -0
  20. package/dist/upload.js +40 -10
  21. package/dist/workspace.js +5 -3
  22. package/package.json +11 -2
  23. package/renderer/src/components/AuthorByline.astro +20 -11
  24. package/renderer/src/components/BlogHome.astro +14 -57
  25. package/renderer/src/components/Footer.astro +12 -9
  26. package/renderer/src/components/Header.astro +38 -0
  27. package/renderer/src/components/MobileMenuIcons.astro +7 -0
  28. package/renderer/src/components/PostCard.astro +12 -5
  29. package/renderer/src/components/SocialLink.astro +4 -4
  30. package/renderer/src/layouts/SiteLayout.astro +70 -51
  31. package/renderer/src/lib/analytics.ts +21 -0
  32. package/renderer/src/lib/author-dialog.ts +102 -0
  33. package/renderer/src/lib/authors.js +18 -0
  34. package/renderer/src/lib/block-widths.ts +35 -0
  35. package/renderer/src/lib/branding.js +15 -0
  36. package/renderer/src/lib/button.js +10 -0
  37. package/renderer/src/lib/category-routes.js +19 -0
  38. package/renderer/src/lib/code-block.ts +46 -0
  39. package/renderer/src/lib/font-fallbacks.js +196 -0
  40. package/renderer/src/lib/fonts.js +48 -0
  41. package/renderer/src/lib/home-layout.ts +5 -0
  42. package/renderer/src/lib/image-radius.ts +5 -0
  43. package/renderer/src/lib/image-rounding.ts +28 -0
  44. package/renderer/src/lib/image-widths.ts +13 -0
  45. package/renderer/src/lib/metadata.js +47 -0
  46. package/renderer/src/lib/mobile-navigation.ts +76 -0
  47. package/renderer/src/lib/post-layout.ts +6 -0
  48. package/renderer/src/lib/post-share.ts +264 -0
  49. package/renderer/src/lib/post-toc.ts +69 -0
  50. package/renderer/src/lib/posts.js +14 -6
  51. package/renderer/src/lib/publication-url.js +14 -0
  52. package/renderer/src/lib/table-styles.ts +12 -0
  53. package/renderer/src/lib/urls.js +3 -0
  54. package/renderer/src/pages/[...slug].astro +21 -11
  55. package/renderer/src/pages/category/[tag].astro +16 -0
  56. package/renderer/src/pages/category/index.astro +4 -0
  57. package/renderer/src/pages/index.astro +1 -1
  58. package/renderer/src/styles/post-share.css +57 -0
  59. package/renderer/src/styles/site.css +261 -91
  60. package/dist/public-assets.js +0 -29
package/dist/workspace.js CHANGED
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { escapePath } from 'tinyglobby';
7
- import { copyPublicAssets } from './public-assets.js';
7
+ import { writeCustomLayout } from './custom-layout.js';
8
8
  const require = createRequire(import.meta.url);
9
9
  const renderer = fileURLToPath(new URL('../renderer/', import.meta.url));
10
10
  export async function prepareWorkspace(directory) {
@@ -15,7 +15,7 @@ export async function prepareWorkspace(directory) {
15
15
  const workspace = await realpath(await mkdtemp(path.join(tmpdir(), 'lettercamp-')));
16
16
  try {
17
17
  await cp(renderer, workspace, { recursive: true });
18
- await copyPublicAssets(contentRoot, workspace);
18
+ await writeCustomLayout(workspace, contentRoot);
19
19
  await writeFile(path.join(workspace, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
20
20
  // Dependencies stay in the CLI installation; the glob uses real source paths
21
21
  // so Vite sees additions and deletions without a symlink/realpath mismatch.
@@ -25,7 +25,9 @@ export async function prepareWorkspace(directory) {
25
25
  await writeFile(path.join(workspace, 'src', 'content.js'), `export const contentPrefix = ${JSON.stringify(contentPrefix)};\n` +
26
26
  `const allPages = import.meta.glob(${JSON.stringify(escapePath(contentPrefix) + '*.{md,mdx}')}, { eager: true });\n` +
27
27
  `export const pages = Object.fromEntries(Object.entries(allPages).filter(([filename, page]) => !/\\/index\\.mdx?$/.test(filename) && page.frontmatter?.draft !== true));\n`);
28
- await writeFile(path.join(workspace, 'src', 'site.js'), `export { default } from ${JSON.stringify(path.join(contentRoot, 'settings.json').replaceAll('\\', '/'))};\n`);
28
+ await writeFile(path.join(workspace, 'src', 'site.js'), `import source from ${JSON.stringify(path.join(contentRoot, 'settings.json').replaceAll('\\', '/'))};\n` +
29
+ `import { withDefaultBranding } from './lib/branding.js';\n` +
30
+ `export default withDefaultBranding(source);\n`);
29
31
  return { workspace, contentRoot, modules, cleanup: () => rm(workspace, { recursive: true, force: true, maxRetries: 3 }) };
30
32
  }
31
33
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lettercamp/cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "The repository for the Lettercamp CLI",
5
5
  "keywords": [
6
6
  "lettercamp",
@@ -26,7 +26,7 @@
26
26
  "renderer"
27
27
  ],
28
28
  "engines": {
29
- "node": ">=22.17.0"
29
+ "node": ">=22.22.0"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc",
@@ -40,9 +40,18 @@
40
40
  "dependencies": {
41
41
  "@astrojs/markdown-remark": "^7.3.3",
42
42
  "@astrojs/mdx": "8.0.3",
43
+ "@astrojs/react": "7.0.0",
44
+ "@codemirror/language-data": "^6.5.2",
45
+ "@lezer/highlight": "^1.2.5",
43
46
  "astro": "7.3.8",
44
47
  "commander": "^15.0.0",
45
48
  "giget": "^3.3.1",
49
+ "hast-util-from-html": "^2.0.3",
50
+ "hast-util-to-html": "^9.0.5",
51
+ "highlight.js": "^11.12.0",
52
+ "react": "19.3.1",
53
+ "react-dom": "19.3.1",
54
+ "react-icons": "^5.7.0",
46
55
  "semver": "^7.7.3",
47
56
  "tinyglobby": "^0.2.17"
48
57
  },
@@ -1,17 +1,26 @@
1
1
  ---
2
- const { post, site, variant = 'article' } = Astro.props;
3
- const author = post.author ?? site.author;
4
- const authorImage = post.authorImage ?? (author === site.author ? site.authorImage : undefined);
5
- const showReadingTime = variant === 'article' && site.showReadingTime !== false && typeof post.readingMinutes === 'number';
6
- const showDate = variant === 'listing' && post.date?.label;
2
+ import { resolveAuthors } from '../lib/authors.js';
3
+ const { post, site, variant = 'article', date = post.date } = Astro.props;
4
+ const authors = resolveAuthors(post).filter(author => author.name.trim());
5
+ const showReadingTime = variant === 'article' && site.post?.showReadingTime !== false && typeof post.readingMinutes === 'number';
6
+ const showDate = date?.label;
7
7
  ---
8
8
 
9
- {(author || showReadingTime || showDate) && <div class="post-byline">
10
- {author && <span class="post-author">
11
- {authorImage && <img class="author-avatar" src={authorImage} width="24" height="24" alt="" loading="lazy" />}
12
- <span>{author}</span>
9
+ {(authors.length > 0 || showReadingTime || showDate) && <div class="post-byline">
10
+ {showDate && <time datetime={date.iso}>{date.label}</time>}
11
+ {authors.length > 0 && <span class="post-authors">
12
+ <span class="author-portraits" aria-hidden="true">
13
+ {authors.map(author => <span class="author-portrait" title={author.name}>
14
+ {author.image ? <img class="author-avatar" src={author.image} width="24" height="24" alt="" loading="lazy" /> :
15
+ <span class="author-initials">{author.name.trim().split(/\s+/).map(part => part[0]).slice(0, 2).join('')}</span>}
16
+ </span>)}
17
+ </span>
18
+ <span class="author-names">
19
+ {authors.length <= 2 ? authors.map(author => author.name).join(', ') : <>
20
+ {authors.slice(0, 2).map(author => author.name).join(', ')}, and{' '}
21
+ <button type="button" class="author-more" aria-haspopup="dialog" data-authors={JSON.stringify(authors)}>{authors.length - 2} more</button>
22
+ </>}
23
+ </span>
13
24
  </span>}
14
- {author && showDate && <span class="byline-separator" aria-hidden="true">·</span>}
15
- {showDate && <time datetime={post.date.iso}>{post.date.label}</time>}
16
25
  {showReadingTime && <span class="reading-time">{post.readingMinutes} min read</span>}
17
26
  </div>}
@@ -1,26 +1,29 @@
1
1
  ---
2
+ import { sitePath } from '../lib/urls.js';
2
3
  import PostCard from './PostCard.astro';
3
- import { getPosts } from '../lib/posts.js';
4
- const { site } = Astro.props;
5
- const posts = getPosts(site.language);
6
- const categories = [...new Set(posts.map(post => post.category).filter(Boolean))];
7
- const showCategories = posts.length > 1 && categories.length > 0;
4
+ import { getPosts, getCategories } from '../lib/posts.js';
5
+ import { readHomeWidthPx } from '../lib/home-layout';
6
+ const { site, category = '' } = Astro.props;
7
+ const allPosts = getPosts(site.language, site.timeZone);
8
+ const posts = allPosts.filter(post => !category || post.category === category);
9
+ const categories = getCategories();
10
+ const hasCategories = categories.length > 0;
8
11
  const featuredLimit = 9;
9
12
  ---
10
13
 
11
- <div class="blog-home" data-blog-home>
14
+ <div class="blog-home" data-blog-home data-home-layout={site.home?.feedLayout === 'rows' ? 'rows' : 'grid'} style={{ '--home-content-width': `${readHomeWidthPx(site.home)}px` }}>
12
15
  <header class="blog-heading">
13
- <h1>{site.homeTitle ?? `${site.name} Blog`}</h1>
16
+ <h1>{category || (site.home.title ?? `${site.name} Blog`)}</h1>
14
17
  {site.description && <p class="description" style="margin-top: 12px">{site.description}</p>}
15
18
  </header>
16
- {showCategories && <nav class="category-selector" aria-label="Filter posts by category">
17
- <a href="/" data-category="" aria-current="page">All posts</a>
18
- {categories.map(category => <a href={`?category=${encodeURIComponent(category)}`} data-category={category}>{category}</a>)}
19
+ {hasCategories && <nav class="category-selector" aria-label="Filter posts by category" hidden={site.home?.showCategories === false}>
20
+ <a href={sitePath("/")} data-category="" aria-current={!category ? "page" : undefined}>All</a>
21
+ {categories.map(item => <a href={item.href} data-category={item.name} aria-current={category === item.name ? "page" : undefined}>{item.name}</a>)}
19
22
  </nav>}
20
23
  <p class="sr-only" data-results-count role="status" aria-live="polite">{posts.length} articles</p>
21
24
  <section aria-label="Recent articles">
22
25
  <h2 class="sr-only">Recent articles</h2>
23
- <div class:list={['post-list', { 'post-list-rows': posts.length < 3 }]} data-featured-posts>
26
+ <div class="post-list" data-featured-posts>
24
27
  {posts.slice(0, featuredLimit).map(post => <PostCard post={post} site={site} />)}
25
28
  </div>
26
29
  <p class="empty-state" data-empty-state hidden={posts.length > 0}>No articles here yet.</p>
@@ -32,49 +35,3 @@ const featuredLimit = 9;
32
35
  </div>
33
36
  </section>
34
37
  </div>
35
-
36
- <script>
37
- const blog = document.querySelector<HTMLElement>('[data-blog-home]');
38
- if (blog) {
39
- const links = [...blog.querySelectorAll<HTMLAnchorElement>('[data-category]')];
40
- // Preserve the original date order as cards move between the two lists.
41
- const posts = [...blog.querySelectorAll<HTMLElement>('[data-post-category]')];
42
- const featured = blog.querySelector<HTMLElement>('[data-featured-posts]')!;
43
- const archive = blog.querySelector<HTMLElement>('[data-archive]')!;
44
- const archivePosts = blog.querySelector<HTMLElement>('[data-archive-posts]')!;
45
- const empty = blog.querySelector<HTMLElement>('[data-empty-state]')!;
46
- const count = blog.querySelector<HTMLElement>('[data-results-count]')!;
47
- const featuredLimit = 9;
48
- function filter(category: string) {
49
- // An old shared filter must not hide the only post when controls are absent.
50
- category = links.length ? category : '';
51
- const matching = posts.filter(post => !category || post.dataset.postCategory === category);
52
- for (const post of posts) post.hidden = Boolean(category) && post.dataset.postCategory !== category;
53
- matching.forEach((post, index) => (index < featuredLimit ? featured : archivePosts).append(post));
54
- featured.classList.toggle('post-list-rows', matching.length < 3);
55
- archive.hidden = matching.length <= featuredLimit;
56
- for (const link of links) {
57
- if (link.dataset.category === category) link.setAttribute('aria-current', 'page');
58
- else link.removeAttribute('aria-current');
59
- }
60
- empty.hidden = matching.length > 0;
61
- empty.textContent = category ? `No articles in ${category} yet. Choose All posts to explore the blog.` : 'No articles here yet.';
62
- const archived = Math.max(0, matching.length - featuredLimit);
63
- count.textContent = `${matching.length} ${matching.length === 1 ? 'article' : 'articles'}${category ? ` in ${category}` : ''}${archived ? `: ${featuredLimit} recent, ${archived} in Archive` : ''}`;
64
- }
65
- function select(category: string) {
66
- const url = new URL(window.location.href);
67
- if (category) url.searchParams.set('category', category);
68
- else url.searchParams.delete('category');
69
- if (url.href !== window.location.href) window.history.pushState(null, '', url);
70
- filter(category);
71
- }
72
- for (const link of links) link.addEventListener('click', (event) => {
73
- if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
74
- event.preventDefault();
75
- select(link.dataset.category ?? '');
76
- });
77
- window.addEventListener('popstate', () => filter(new URL(window.location.href).searchParams.get('category') ?? ''));
78
- filter(new URL(window.location.href).searchParams.get('category') ?? '');
79
- }
80
- </script>
@@ -1,32 +1,35 @@
1
1
  ---
2
+ import { sitePath } from '../lib/urls.js';
2
3
  import SocialLink from './SocialLink.astro';
3
4
  const { site } = Astro.props;
4
- const columns = (site.footer?.links ?? []).slice(0, 5);
5
- const socials = Object.entries(site.footer?.socials ?? {});
5
+ const logo = site.logo;
6
+ const columns = (site.footer?.columns ?? []).slice(0, 4);
7
+ const socials = (site.footer?.socials ?? []).filter(({ href }) => {
8
+ try { return typeof href === 'string' && ['https:', 'http:'].includes(new URL(href).protocol); }
9
+ catch { return false; }
10
+ });
6
11
  ---
7
12
 
8
13
  <footer class="site-footer">
9
14
  <div class:list={['footer-inner', { 'footer-has-columns': columns.length > 0 }]} style={`--footer-columns: ${Math.max(columns.length, 1)}; --footer-tablet-columns: ${Math.min(Math.max(columns.length, 1), 3)}; --footer-mobile-columns: ${Math.min(Math.max(columns.length, 1), 2)}`}>
10
15
  <div class="footer-main">
11
16
  <div class="footer-brand">
12
- <a class="brand" href="/" aria-label={`${site.name} home`}>
13
- {site.favicon && <img src={site.favicon} width="24" height="24" alt="" />}
14
- {!site.favicon && <span>{site.name}</span>}
17
+ <a class="brand" href={sitePath("/")} aria-label={`${site.name} home`}>
18
+ <img src={logo} width="24" height="24" alt="" />
15
19
  </a>
16
20
  </div>
17
21
  {columns.length > 0 && <div class="footer-columns">
18
22
  {columns.map((column, index) => <nav class="footer-column" aria-label={column.header ?? `Footer links ${index + 1}`}>
19
23
  {column.header && <h2>{column.header}</h2>}
20
- <ul>{(column.items ?? []).map(item => <li><a href={item.href}>{item.label}</a></li>)}</ul>
24
+ <ul>{(column.items ?? []).map(item => <li><a href={sitePath(item.href)}>{item.label}</a></li>)}</ul>
21
25
  </nav>)}
22
26
  </div>}
23
27
  </div>
24
28
  <div class="footer-bottom">
25
- <span>© {new Date().getFullYear()} {site.name}</span>
26
29
  {socials.length > 0 && <nav class="footer-socials" aria-label="Social links">
27
- {socials.map(([name, href]) => <SocialLink name={name} href={href} />)}
30
+ {socials.map(({ platform, href }) => <SocialLink name={platform} href={href} />)}
28
31
  </nav>}
29
- <a class="footer-credit" href="https://lettercamp.com">Published with Lettercamp <span aria-hidden="true">↗</span></a>
32
+ <a class="footer-credit" href="https://lettercamp.com">Made with <strong>Lettercamp</strong></a>
30
33
  </div>
31
34
  </div>
32
35
  </footer>
@@ -0,0 +1,38 @@
1
+ ---
2
+ import { sitePath } from '../lib/urls.js';
3
+ import { buttonColors } from '../lib/button.js';
4
+ import MobileMenuIcons from './MobileMenuIcons.astro';
5
+ const { site, pathname } = Astro.props;
6
+ const navLinks = site.header?.links ?? [{ label: 'Home', href: '/' }];
7
+ const primary = site.header?.button;
8
+ const primaryColors = buttonColors(primary?.color);
9
+ const primaryStyle = { '--button-color': primaryColors.background, '--button-text': primaryColors.text };
10
+ const logo = site.logo;
11
+ ---
12
+
13
+ <header class="site-header" data-header-position={site.header?.position === 'fixed' ? 'fixed' : 'scroll'}>
14
+ <template data-mobile-menu-icons><MobileMenuIcons /></template>
15
+ <div class="site-header-inner">
16
+ <a class="brand" href={sitePath("/")}>
17
+ <img src={logo} width="30" height="30" alt="" />
18
+ <span>{site.name}</span>
19
+ </a>
20
+ {(navLinks.length > 0 || primary) && <div class="header-actions">
21
+ {navLinks.length > 0 &&
22
+ <nav class="desktop-navigation" aria-label="Main navigation">
23
+ {navLinks.map((item) =>
24
+ <a href={sitePath(item.href)} aria-current={sitePath(item.href) === pathname ? 'page' : undefined}>{item.label}</a>
25
+ )}
26
+ </nav>}
27
+ {primary && <a class="header-primary" style={primaryStyle} href={sitePath(primary.href)}>{primary.label}</a>}
28
+ {navLinks.length > 0 && <details class="mobile-navigation">
29
+ <summary class="mobile-menu-toggle" aria-label="Open menu" aria-expanded="false" aria-controls="mobile-menu-panel"><MobileMenuIcons /></summary>
30
+ <nav id="mobile-menu-panel" class="mobile-menu-panel" aria-label="Mobile navigation">
31
+ {navLinks.map((item) =>
32
+ <a href={sitePath(item.href)} aria-current={sitePath(item.href) === pathname ? 'page' : undefined}>{item.label}</a>
33
+ )}
34
+ </nav>
35
+ </details>}
36
+ </div>}
37
+ </div>
38
+ </header>
@@ -0,0 +1,7 @@
1
+ ---
2
+ import { LuMenu } from 'react-icons/lu';
3
+ import { MdClose } from 'react-icons/md';
4
+ ---
5
+
6
+ <LuMenu className="mobile-menu-icon" aria-hidden="true" />
7
+ <MdClose className="mobile-close-icon" aria-hidden="true" />
@@ -1,18 +1,25 @@
1
1
  ---
2
2
  import AuthorByline from './AuthorByline.astro';
3
3
  const { post, site } = Astro.props;
4
- const image = post.ogImage ?? post.image ?? site.ogImage;
4
+ const image = post.ogImage ?? post.image ?? site.seo?.ogImage;
5
5
  ---
6
6
 
7
7
  <article class="post-card" data-post-category={post.category}>
8
- <a class="post-card-link" href={post.href}>
8
+ <div class="post-card-layout">
9
9
  <div class="post-card-cover">
10
10
  {image ? <img src={image} alt="" width="1200" height="630" loading="lazy" /> : <span class="image-placeholder" aria-hidden="true">{site.name}</span>}
11
11
  </div>
12
12
  <div class="post-card-content">
13
- <h3>{post.title}</h3>
13
+ {(post.category || post.date?.label) && <div class="post-card-details">
14
+ {post.date?.label && <time class="post-card-date" datetime={post.date.iso}>{post.date.label}</time>}
15
+ {post.category && <span class="post-card-category">{post.category}</span>}
16
+ </div>}
17
+ <h3><a class="post-card-link" href={post.href}>{post.title}</a></h3>
14
18
  {post.description && <p class="post-card-description">{post.description}</p>}
15
- <AuthorByline post={post} site={site} variant="listing" />
19
+ <div class="post-card-meta">
20
+ <AuthorByline post={post} site={site} variant="listing" date={null} />
21
+ {post.date?.label && <time class="archive-date" datetime={post.date.iso}>{post.date.label}</time>}
22
+ </div>
16
23
  </div>
17
- </a>
24
+ </div>
18
25
  </article>
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  const { name, href } = Astro.props;
3
- const aliases = { twitter: 'x', 'x-twitter': 'x', 'earth-americas': 'website' };
4
- const platform = aliases[name] ?? name;
5
- const names = { x: 'X', github: 'GitHub', linkedin: 'LinkedIn', youtube: 'YouTube', instagram: 'Instagram', facebook: 'Facebook', website: 'Website', 'hacker-news': 'Hacker News', bluesky: 'Bluesky', discord: 'Discord', slack: 'Slack', medium: 'Medium', telegram: 'Telegram', threads: 'Threads', reddit: 'Reddit', podcast: 'Podcast' };
3
+ const platform = name.trim().toLowerCase();
4
+ const names = { x: 'Twitter / X', tiktok: 'TikTok', github: 'GitHub', linkedin: 'LinkedIn', youtube: 'YouTube', instagram: 'Instagram', facebook: 'Facebook', website: 'Website', 'hacker-news': 'Hacker News', bluesky: 'Bluesky', discord: 'Discord', slack: 'Slack', medium: 'Medium', telegram: 'Telegram', threads: 'Threads', reddit: 'Reddit', podcast: 'Podcast' };
6
5
  const label = names[platform] ?? name.replaceAll('-', ' ').replace(/^./, character => character.toUpperCase());
7
6
  const icons = {
7
+ tiktok: 'M16.6 1c.4 3 2.1 4.8 5.4 5v4a9.5 9.5 0 0 1-5.4-1.7v7.4A7.3 7.3 0 1 1 10.3 8v4.1a3.3 3.3 0 1 0 2.3 3.2V1h4Z',
8
8
  x: 'M18.9 2H22l-6.8 7.8L23.2 22h-6.3l-5-7.6L5.2 22H2l8.4-9.6L.8 2h6.5l4.6 7L18.9 2Zm-1.1 18h1.7L6.3 4H4.5l13.3 16Z',
9
9
  github: 'M12 .8a11.2 11.2 0 0 0-3.54 21.83c.56.1.77-.24.77-.54v-2.1c-3.13.68-3.79-1.33-3.79-1.33-.51-1.3-1.25-1.65-1.25-1.65-1.02-.7.08-.69.08-.69 1.13.08 1.72 1.16 1.72 1.16 1 .1 1.83 1.65 3.28.73.1-.73.39-1.23.71-1.51-2.5-.28-5.13-1.25-5.13-5.56 0-1.23.44-2.23 1.16-3.02-.12-.28-.5-1.43.11-2.98 0 0 .95-.3 3.08 1.15a10.7 10.7 0 0 1 5.6 0c2.13-1.45 3.08-1.15 3.08-1.15.61 1.55.23 2.7.11 2.98.72.79 1.16 1.79 1.16 3.02 0 4.32-2.63 5.27-5.14 5.55.4.35.76 1.03.76 2.08v3.32c0 .3.2.65.77.54A11.2 11.2 0 0 0 12 .8Z',
10
10
  linkedin: 'M20.5 2h-17C2.67 2 2 2.67 2 3.5v17c0 .83.67 1.5 1.5 1.5h17c.83 0 1.5-.67 1.5-1.5v-17c0-.83-.67-1.5-1.5-1.5ZM8 19H5V9h3v10ZM6.5 7.7a1.8 1.8 0 1 1 0-3.6 1.8 1.8 0 0 1 0 3.6ZM19 19h-3v-5.3c0-1.3-.5-2.1-1.6-2.1-1.2 0-1.9.8-1.9 2.1V19h-3V9h2.9v1.4C13 9.5 14 8.8 15.4 8.8c2.4 0 3.6 1.5 3.6 4.3V19Z',
@@ -13,7 +13,7 @@ const icons = {
13
13
  };
14
14
  const hasIcon = icons[platform] || ['instagram', 'website', 'hacker-news'].includes(platform);
15
15
  ---
16
- <a href={href} aria-label={label} title={label}>
16
+ <a href={href} aria-label={label} title={label} rel="me noopener noreferrer" target="_blank">
17
17
  {hasIcon ? <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
18
18
  {icons[platform] && <path d={icons[platform]} />}
19
19
  {platform === 'instagram' && <g fill="none" stroke="currentColor" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="5" /><circle cx="12" cy="12" r="4" /><circle cx="17.5" cy="6.5" r=".8" fill="currentColor" stroke="none" /></g>}
@@ -1,70 +1,89 @@
1
1
  ---
2
2
  import '../styles/site.css';
3
- import Footer from '../components/Footer.astro';
3
+ import '../styles/post-share.css';
4
+ import { basePath } from '../lib/urls.js';
5
+ import { codeBlockStyles } from '../lib/code-block';
6
+ import { imageRoundingScript, readImageRadius } from '../lib/image-rounding';
7
+ import { fontResources } from '../lib/fonts.js';
8
+ import { pageMetadata } from '../lib/metadata.js';
9
+ import { defaultSiteBranding } from '../lib/branding.js';
10
+ import { postTocScript } from '../lib/post-toc';
11
+ import { authorDialogScript } from '../lib/author-dialog';
12
+ import { postShareScript } from '../lib/post-share';
13
+ import { mobileNavigationScript } from '../lib/mobile-navigation';
14
+ import { blockWidthStyles } from '../lib/block-widths';
15
+ import { tableStyles } from '../lib/table-styles';
16
+ import { imageWidthStyles } from '../lib/image-widths';
17
+ import { googleAnalyticsScript } from '../lib/analytics';
18
+ import Header from '../components/HeaderSlot.astro';
19
+ import Footer from '../components/FooterSlot.astro';
4
20
 
5
21
  const { site, page, article = false } = Astro.props;
6
- const navLinks = site.navbar?.links ?? site.navigation ?? [{ label: 'Home', href: '/' }];
7
- const primary = site.navbar?.primary;
8
- const title = page.title ?? site.name;
9
- const description = page.description ?? site.description;
10
- const image = page.ogImage ?? page.image ?? site.ogImage;
11
- const imageUrl = image || undefined;
12
- const typography = site.typography ?? {};
13
- const fontFamily = typeof typography.fontFamily === 'string' && typography.fontFamily.trim() ? typography.fontFamily.trim() : 'Inter';
22
+ const meta = pageMetadata(site, page, Astro.url, article, basePath);
23
+ const typography = site.design?.typography ?? {};
24
+ const fonts = fontResources(typography);
14
25
  const fontSize = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
15
- const headingSize = fontSize(typography.headingSize, 48);
16
- const bodySize = fontSize(typography.bodySize, 18);
17
- const fontUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontFamily)}:wght@400;500;600;700&display=swap`;
26
+ const baseSize = fontSize(typography.baseSizePx, 16);
27
+ const bodyLineHeight = fontSize(typography.bodyLineHeight, 1.8);
28
+ const backgroundColor = typeof site.design?.backgroundColor === 'string' && /^#[0-9a-f]{6}$/i.test(site.design?.backgroundColor) ? site.design?.backgroundColor : '#ffffff';
29
+ const analytics = googleAnalyticsScript(site.analytics?.googleAnalyticsId);
18
30
  ---
19
31
 
20
32
  <!doctype html>
21
- <html lang={site.language ?? 'en'} style={{ '--font-family': `${JSON.stringify(fontFamily)}, Arial, Helvetica, sans-serif`, '--heading-size': `${headingSize / 16}rem`, '--body-size': `${bodySize / 16}rem` }}>
33
+ <html data-base-path={basePath} lang={meta.language} style={{ '--image-radius': readImageRadius(site.design?.imageRadiusPercent) / 100, '--site-background': backgroundColor, '--font-family': fonts.bodyFamily, '--heading-font': fonts.headingFamily, '--type-scale': baseSize / 16, '--body-line-height': bodyLineHeight }}>
22
34
  <head>
23
35
  <meta charset="utf-8" />
24
36
  <meta name="viewport" content="width=device-width, initial-scale=1" />
25
- <link rel="preconnect" href="https://fonts.googleapis.com" />
26
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
27
- <link href={fontUrl} rel="stylesheet" />
28
- <title>{title === site.name ? title : `${title} · ${site.name}`}</title>
29
- <meta name="description" content={description} />
30
- <meta property="og:title" content={title} />
31
- <meta property="og:description" content={description} />
37
+ {fonts.googleUrl && <>
38
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
39
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
40
+ <link data-lettercamp-fonts href={fonts.googleUrl} rel="stylesheet" />
41
+ </>}
42
+ <style is:inline data-lettercamp-font-faces set:html={fonts.css} />
43
+ <style is:inline set:html={imageWidthStyles} />
44
+ <style is:inline set:html={blockWidthStyles} />
45
+ <style is:inline set:html={codeBlockStyles} />
46
+ <style is:inline set:html={tableStyles} />
47
+ <title>{meta.documentTitle}</title>
48
+ <meta name="description" content={meta.description} />
49
+ {meta.keywords && <meta name="keywords" content={meta.keywords} />}
50
+ <meta name="robots" content={meta.robots} />
51
+ <link rel="canonical" href={meta.canonical} />
52
+ <meta property="og:title" content={meta.socialTitle} />
53
+ <meta property="og:description" content={meta.socialDescription} />
32
54
  <meta property="og:site_name" content={site.name} />
33
55
  <meta property="og:type" content={article ? 'article' : 'website'} />
34
- <meta property="og:url" content={Astro.url.href} />
35
- {imageUrl && <meta property="og:image" content={imageUrl} />}
36
- <meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
37
- {site.favicon && <link rel="icon" href={site.favicon} />}
56
+ <meta property="og:url" content={meta.canonical} />
57
+ {meta.locale && <meta property="og:locale" content={meta.locale} />}
58
+ {meta.imageUrl && <meta property="og:image" content={meta.imageUrl} />}
59
+ {meta.imageUrl && meta.imageAlt && <meta property="og:image:alt" content={meta.imageAlt} />}
60
+ {meta.videoUrl && <meta property="og:video" content={meta.videoUrl} />}
61
+ {meta.videoUrl?.startsWith('https:') && <meta property="og:video:secure_url" content={meta.videoUrl} />}
62
+ {meta.videoUrl && meta.videoType && <meta property="og:video:type" content={meta.videoType} />}
63
+ <meta name="twitter:card" content={meta.twitterCard} />
64
+ <meta name="twitter:title" content={meta.twitterTitle} />
65
+ <meta name="twitter:description" content={meta.twitterDescription} />
66
+ {meta.twitterImageUrl && <meta name="twitter:image" content={meta.twitterImageUrl} />}
67
+ {meta.twitterImageUrl && meta.twitterImageAlt && <meta name="twitter:image:alt" content={meta.twitterImageAlt} />}
68
+ {meta.twitterSite && <meta name="twitter:site" content={meta.twitterSite} />}
69
+ {meta.twitterCreator && <meta name="twitter:creator" content={meta.twitterCreator} />}
70
+ <link rel="icon" href={site.favicon} />
71
+ {site.favicon === defaultSiteBranding.favicon && <>
72
+ <link rel="icon" href="https://lettercamp.com/favicon.svg?v=7" type="image/svg+xml" />
73
+ <link rel="shortcut icon" href="https://lettercamp.com/favicon.ico?v=7" />
74
+ <link rel="apple-touch-icon" href="https://lettercamp.com/apple-touch-icon.png?v=7" sizes="180x180" />
75
+ </>}
76
+ {analytics && <script is:inline data-lettercamp-analytics set:html={analytics} />}
38
77
  </head>
39
78
  <body>
40
79
  <a class="skip-link" href="#content">Skip to content</a>
41
- <header class="site-header">
42
- <div class="site-header-inner">
43
- <a class="brand" href="/">
44
- {site.favicon && <img src={site.favicon} width="30" height="30" alt="" />}
45
- <span>{site.name}</span>
46
- </a>
47
- {(navLinks.length > 0 || primary) && <div class="header-actions">
48
- {navLinks.length > 0 &&
49
- <nav class="desktop-navigation" aria-label="Main navigation">
50
- {navLinks.map((item) =>
51
- <a href={item.href} aria-current={item.href === Astro.url.pathname ? 'page' : undefined}>{item.label}</a>
52
- )}
53
- </nav>}
54
- <details class="mobile-navigation">
55
- <summary>Menu</summary>
56
- <nav aria-label="Main navigation">
57
- {navLinks.map((item) =>
58
- <a href={item.href} aria-current={item.href === Astro.url.pathname ? 'page' : undefined}>{item.label}</a>
59
- )}
60
- {primary && <a href={primary.href}>{primary.label}</a>}
61
- </nav>
62
- </details>
63
- {primary && <a class="header-primary" href={primary.href}>{primary.label}</a>}
64
- </div>}
65
- </div>
66
- </header>
80
+ <Header site={site} pathname={Astro.url.pathname} />
67
81
  <main id="content"><slot /></main>
68
- <Footer site={site} />
82
+ <Footer site={site} pathname={Astro.url.pathname} />
83
+ <script is:inline data-lettercamp-images set:html={imageRoundingScript} />
84
+ <script is:inline data-lettercamp-toc set:html={postTocScript} />
85
+ <script is:inline data-lettercamp-authors set:html={authorDialogScript} />
86
+ <script is:inline data-lettercamp-share set:html={postShareScript} />
87
+ <script is:inline data-lettercamp-navigation set:html={mobileNavigationScript} />
69
88
  </body>
70
89
  </html>
@@ -0,0 +1,21 @@
1
+ export function googleAnalyticsScript(value: unknown): string {
2
+ const id = typeof value === 'string' ? value.trim().toUpperCase() : '';
3
+ if (!/^G-[A-Z0-9]+$/.test(id)) return '';
4
+ return `(() => {
5
+ const url = new URL(window.location.href);
6
+ const host = url.hostname;
7
+ if (!['http:', 'https:'].includes(url.protocol)
8
+ || host === 'localhost' || host.endsWith('.localhost') || /^127\\./.test(host) || host === '[::1]'
9
+ || url.searchParams.get('__lettercamp_preview') === '1' || url.pathname.startsWith('/_deployments/')) return;
10
+ if (document.querySelector('script[data-lettercamp-google-tag]')) return;
11
+ window.dataLayer = window.dataLayer || [];
12
+ window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };
13
+ window.gtag('js', new Date());
14
+ window.gtag('config', '${id}');
15
+ const script = document.createElement('script');
16
+ script.async = true;
17
+ script.dataset.lettercampGoogleTag = '';
18
+ script.src = 'https://www.googletagmanager.com/gtag/js?id=${id}';
19
+ document.head.appendChild(script);
20
+ })();`;
21
+ }
@@ -0,0 +1,102 @@
1
+ // Delegation also supports bylines replaced by the editor preview bridge.
2
+ export const authorDialogScript = `
3
+ (() => {
4
+ let panel;
5
+ let trigger;
6
+ let tracking;
7
+ let frame;
8
+ const element = (tag, className, text) => {
9
+ const node = document.createElement(tag);
10
+ node.className = className;
11
+ if (text != null) node.textContent = text;
12
+ return node;
13
+ };
14
+ const close = (restoreFocus = false) => {
15
+ if (!panel?.matches(':popover-open')) return;
16
+ panel.hidePopover();
17
+ trigger?.setAttribute('aria-expanded', 'false');
18
+ tracking?.abort();
19
+ cancelAnimationFrame(frame);
20
+ if (restoreFocus && trigger?.isConnected) trigger.focus({ preventScroll: true });
21
+ };
22
+ const position = () => {
23
+ if (!panel?.matches(':popover-open')) return;
24
+ if (!trigger?.isConnected) { close(); return; }
25
+ const anchor = trigger.getBoundingClientRect();
26
+ const viewport = window.visualViewport;
27
+ const left = viewport?.offsetLeft || 0;
28
+ const top = viewport?.offsetTop || 0;
29
+ const width = viewport?.width || innerWidth;
30
+ const height = viewport?.height || innerHeight;
31
+ if (anchor.bottom < top || anchor.top > top + height) { close(); return; }
32
+ const below = top + height - anchor.bottom - 14;
33
+ const above = anchor.top - top - 14;
34
+ const upwards = below < Math.min(panel.scrollHeight, 320) && above > below;
35
+ panel.style.maxHeight = Math.max(40, Math.min(320, upwards ? above : below)) + 'px';
36
+ panel.style.left = Math.max(left + 8, Math.min(anchor.left, left + width - panel.offsetWidth - 8)) + 'px';
37
+ panel.style.top = (upwards ? anchor.top - panel.offsetHeight - 6 : anchor.bottom + 6) + 'px';
38
+ };
39
+ const schedulePosition = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(position); };
40
+ document.addEventListener('click', event => {
41
+ const button = event.target instanceof Element ? event.target.closest('button.author-more') : null;
42
+ if (!button) return;
43
+ event.preventDefault();
44
+ if (trigger === button && panel?.matches(':popover-open')) { close(true); return; }
45
+ close();
46
+ const authors = JSON.parse(button.dataset.authors);
47
+ if (!panel) {
48
+ panel = element('div', 'authors-popover');
49
+ panel.id = 'authors-popover';
50
+ panel.popover = 'auto';
51
+ panel.tabIndex = -1;
52
+ panel.setAttribute('role', 'dialog');
53
+ panel.setAttribute('aria-label', 'Authors');
54
+ panel.append(element('ul', 'authors-popover-list'));
55
+ panel.addEventListener('toggle', event => {
56
+ if (event.newState === 'closed') {
57
+ trigger?.setAttribute('aria-expanded', 'false');
58
+ tracking?.abort();
59
+ cancelAnimationFrame(frame);
60
+ }
61
+ });
62
+ document.body.append(panel);
63
+ }
64
+ const list = panel.querySelector('ul');
65
+ list.replaceChildren();
66
+ for (const author of authors) {
67
+ const row = element('li', 'authors-popover-author');
68
+ const portrait = element('span', 'author-portrait');
69
+ portrait.setAttribute('aria-hidden', 'true');
70
+ if (author.image) {
71
+ const image = element('img', 'author-avatar');
72
+ image.src = author.image; image.alt = ''; image.width = 28; image.height = 28;
73
+ portrait.append(image);
74
+ } else {
75
+ portrait.append(element('span', 'author-initials', author.name.trim().split(/\\s+/).map(part => part[0]).slice(0, 2).join('')));
76
+ }
77
+ row.append(portrait, element('span', '', author.name));
78
+ list.append(row);
79
+ }
80
+ trigger = button;
81
+ trigger.setAttribute('aria-controls', panel.id);
82
+ trigger.setAttribute('popovertarget', panel.id);
83
+ trigger.setAttribute('aria-expanded', 'true');
84
+ panel.showPopover();
85
+ position();
86
+ panel.focus({ preventScroll: true });
87
+ tracking = new AbortController();
88
+ const signal = tracking.signal;
89
+ document.addEventListener('keydown', event => {
90
+ if (event.key !== 'Escape') return;
91
+ event.preventDefault(); close(true);
92
+ }, { signal, capture: true });
93
+ document.addEventListener('focusin', event => {
94
+ if (!panel.contains(event.target) && event.target !== trigger) close();
95
+ }, { signal });
96
+ document.addEventListener('scroll', schedulePosition, { signal, capture: true, passive: true });
97
+ window.addEventListener('resize', schedulePosition, { signal, passive: true });
98
+ window.visualViewport?.addEventListener('resize', schedulePosition, { signal, passive: true });
99
+ window.visualViewport?.addEventListener('scroll', schedulePosition, { signal, passive: true });
100
+ });
101
+ })();
102
+ `;