@lettercamp/cli 0.1.6 → 0.1.7

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.
@@ -0,0 +1,80 @@
1
+ ---
2
+ 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;
8
+ const featuredLimit = 9;
9
+ ---
10
+
11
+ <div class="blog-home" data-blog-home>
12
+ <header class="blog-heading">
13
+ <h1>{site.homeTitle ?? `${site.name} Blog`}</h1>
14
+ {site.description && <p class="description" style="margin-top: 12px">{site.description}</p>}
15
+ </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
+ </nav>}
20
+ <p class="sr-only" data-results-count role="status" aria-live="polite">{posts.length} articles</p>
21
+ <section aria-label="Recent articles">
22
+ <h2 class="sr-only">Recent articles</h2>
23
+ <div class:list={['post-list', { 'post-list-rows': posts.length < 3 }]} data-featured-posts>
24
+ {posts.slice(0, featuredLimit).map(post => <PostCard post={post} site={site} />)}
25
+ </div>
26
+ <p class="empty-state" data-empty-state hidden={posts.length > 0}>No articles here yet.</p>
27
+ </section>
28
+ <section class="archive" aria-labelledby="archive-heading" data-archive hidden={posts.length <= featuredLimit}>
29
+ <h2 id="archive-heading">Archive</h2>
30
+ <div class="archive-list" data-archive-posts>
31
+ {posts.slice(featuredLimit).map(post => <PostCard post={post} site={site} />)}
32
+ </div>
33
+ </section>
34
+ </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>
@@ -0,0 +1,32 @@
1
+ ---
2
+ import SocialLink from './SocialLink.astro';
3
+ const { site } = Astro.props;
4
+ const columns = (site.footer?.links ?? []).slice(0, 5);
5
+ const socials = Object.entries(site.footer?.socials ?? {});
6
+ ---
7
+
8
+ <footer class="site-footer">
9
+ <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
+ <div class="footer-main">
11
+ <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>}
15
+ </a>
16
+ </div>
17
+ {columns.length > 0 && <div class="footer-columns">
18
+ {columns.map((column, index) => <nav class="footer-column" aria-label={column.header ?? `Footer links ${index + 1}`}>
19
+ {column.header && <h2>{column.header}</h2>}
20
+ <ul>{(column.items ?? []).map(item => <li><a href={item.href}>{item.label}</a></li>)}</ul>
21
+ </nav>)}
22
+ </div>}
23
+ </div>
24
+ <div class="footer-bottom">
25
+ <span>© {new Date().getFullYear()} {site.name}</span>
26
+ {socials.length > 0 && <nav class="footer-socials" aria-label="Social links">
27
+ {socials.map(([name, href]) => <SocialLink name={name} href={href} />)}
28
+ </nav>}
29
+ <a class="footer-credit" href="https://lettercamp.com">Published with Lettercamp <span aria-hidden="true">↗</span></a>
30
+ </div>
31
+ </div>
32
+ </footer>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import AuthorByline from './AuthorByline.astro';
3
+ const { post, site } = Astro.props;
4
+ const image = post.ogImage ?? post.image ?? site.ogImage;
5
+ ---
6
+
7
+ <article class="post-card" data-post-category={post.category}>
8
+ <a class="post-card-link" href={post.href}>
9
+ <div class="post-card-cover">
10
+ {image ? <img src={image} alt="" width="1200" height="630" loading="lazy" /> : <span class="image-placeholder" aria-hidden="true">{site.name}</span>}
11
+ </div>
12
+ <div class="post-card-content">
13
+ <h3>{post.title}</h3>
14
+ {post.description && <p class="post-card-description">{post.description}</p>}
15
+ <AuthorByline post={post} site={site} variant="listing" />
16
+ </div>
17
+ </a>
18
+ </article>
@@ -0,0 +1,23 @@
1
+ ---
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' };
6
+ const label = names[platform] ?? name.replaceAll('-', ' ').replace(/^./, character => character.toUpperCase());
7
+ const icons = {
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
+ 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
+ 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',
11
+ youtube: 'M23 7a3 3 0 0 0-2.1-2.1C19 4.4 12 4.4 12 4.4s-7 0-8.9.5A3 3 0 0 0 1 7a31 31 0 0 0 0 10 3 3 0 0 0 2.1 2.1c1.9.5 8.9.5 8.9.5s7 0 8.9-.5A3 3 0 0 0 23 17a31 31 0 0 0 0-10ZM9.5 15.5v-7l6 3.5-6 3.5Z',
12
+ facebook: 'M22 12a10 10 0 1 0-11.56 9.88v-6.99H7.9V12h2.54V9.8c0-2.5 1.49-3.89 3.77-3.89 1.1 0 2.24.2 2.24.2v2.46h-1.26c-1.24 0-1.63.77-1.63 1.56V12h2.77l-.44 2.89h-2.33v6.99A10 10 0 0 0 22 12Z',
13
+ };
14
+ const hasIcon = icons[platform] || ['instagram', 'website', 'hacker-news'].includes(platform);
15
+ ---
16
+ <a href={href} aria-label={label} title={label}>
17
+ {hasIcon ? <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">
18
+ {icons[platform] && <path d={icons[platform]} />}
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>}
20
+ {platform === 'website' && <g fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9" /><ellipse cx="12" cy="12" rx="4" ry="9" /><path d="M3 12h18" /></g>}
21
+ {platform === 'hacker-news' && <><rect x="2" y="2" width="20" height="20" rx="2" /><path d="m7 6 5 7 5-7m-5 7v5" fill="none" stroke="var(--background, #fff)" stroke-width="1.8" /></>}
22
+ </svg> : <span>{label}</span>}
23
+ </a>
@@ -1,24 +1,36 @@
1
1
  ---
2
2
  import '../styles/site.css';
3
+ import Footer from '../components/Footer.astro';
3
4
 
4
- const { site, page } = Astro.props;
5
+ const { site, page, article = false } = Astro.props;
6
+ const navLinks = site.navbar?.links ?? site.navigation ?? [{ label: 'Home', href: '/' }];
7
+ const primary = site.navbar?.primary;
5
8
  const title = page.title ?? site.name;
6
9
  const description = page.description ?? site.description;
7
10
  const image = page.ogImage ?? page.image ?? site.ogImage;
8
- const imageUrl = image ? new URL(image, Astro.url).href : undefined;
11
+ const imageUrl = image || undefined;
12
+ const typography = site.typography ?? {};
13
+ const fontFamily = typeof typography.fontFamily === 'string' && typography.fontFamily.trim() ? typography.fontFamily.trim() : 'Inter';
14
+ 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`;
9
18
  ---
10
19
 
11
20
  <!doctype html>
12
- <html lang={site.language ?? 'en'}>
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` }}>
13
22
  <head>
14
23
  <meta charset="utf-8" />
15
24
  <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" />
16
28
  <title>{title === site.name ? title : `${title} · ${site.name}`}</title>
17
29
  <meta name="description" content={description} />
18
30
  <meta property="og:title" content={title} />
19
31
  <meta property="og:description" content={description} />
20
32
  <meta property="og:site_name" content={site.name} />
21
- <meta property="og:type" content={page.layout === 'post' ? 'article' : 'website'} />
33
+ <meta property="og:type" content={article ? 'article' : 'website'} />
22
34
  <meta property="og:url" content={Astro.url.href} />
23
35
  {imageUrl && <meta property="og:image" content={imageUrl} />}
24
36
  <meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
@@ -27,17 +39,32 @@ const imageUrl = image ? new URL(image, Astro.url).href : undefined;
27
39
  <body>
28
40
  <a class="skip-link" href="#content">Skip to content</a>
29
41
  <header class="site-header">
42
+ <div class="site-header-inner">
30
43
  <a class="brand" href="/">
31
44
  {site.favicon && <img src={site.favicon} width="30" height="30" alt="" />}
32
45
  <span>{site.name}</span>
33
46
  </a>
34
- <nav aria-label="Main navigation">
35
- {(site.navigation ?? [{ label: 'Home', href: '/' }]).map((item) =>
36
- <a href={item.href} aria-current={item.href === Astro.url.pathname ? 'page' : undefined}>{item.label}</a>
37
- )}
38
- </nav>
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>
39
66
  </header>
40
67
  <main id="content"><slot /></main>
41
- <footer class="site-footer"><span>© {new Date().getFullYear()} {site.name}</span><a href="https://lettercamp.com">Published with Lettercamp</a></footer>
68
+ <Footer site={site} />
42
69
  </body>
43
70
  </html>
@@ -0,0 +1,32 @@
1
+ import { pages, contentPrefix } from '../content.js';
2
+
3
+ export function pageSlug(filename) {
4
+ return filename.slice(contentPrefix.length).replace(/\.(md|mdx)$/, '');
5
+ }
6
+
7
+ export function postDate(value, language = 'en') {
8
+ if (!value) return { iso: '', label: '', timestamp: -Infinity };
9
+ const date = value instanceof Date ? value : new Date(String(value));
10
+ if (Number.isNaN(date.valueOf())) return { iso: '', label: '', timestamp: -Infinity };
11
+ return {
12
+ iso: date.toISOString().slice(0, 10),
13
+ label: new Intl.DateTimeFormat(language, { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(date),
14
+ timestamp: date.valueOf(),
15
+ };
16
+ }
17
+
18
+ export function getPosts(language = 'en') {
19
+ return Object.entries(pages)
20
+ .map(([filename, page]) => {
21
+ const frontmatter = page.frontmatter ?? {};
22
+ const slug = pageSlug(filename);
23
+ return {
24
+ ...frontmatter,
25
+ title: frontmatter.title ?? slug,
26
+ category: typeof frontmatter.category === 'string' ? frontmatter.category.trim() : '',
27
+ href: `/${slug.split('/').map(encodeURIComponent).join('/')}/`,
28
+ date: postDate(frontmatter.date, language),
29
+ };
30
+ })
31
+ .sort((a, b) => b.date.timestamp - a.date.timestamp || a.href.localeCompare(b.href));
32
+ }
@@ -1,41 +1,56 @@
1
1
  ---
2
2
  import SiteLayout from '../layouts/SiteLayout.astro';
3
+ import AuthorByline from '../components/AuthorByline.astro';
4
+ import { pageSlug, postDate } from '../lib/posts.js';
3
5
  import site from '../site.js';
4
- import { pages, contentPrefix } from '../content.js';
6
+ import { pages } from '../content.js';
5
7
 
6
8
  export function getStaticPaths() {
7
9
  return Object.entries(pages).map(([filename, page]) => {
8
- const slug = filename.slice(contentPrefix.length).replace(/\.(md|mdx)$/, '')
9
- .replace(/(^|\/)index$/, '');
10
- return { params: { slug: slug || undefined }, props: { page } };
10
+ const slug = pageSlug(filename);
11
+ return { params: { slug }, props: { page } };
11
12
  });
12
13
  }
13
14
 
14
15
  const { page } = Astro.props;
15
16
  const { Content, frontmatter = {} } = page;
16
- const home = frontmatter.layout === 'home' || Astro.url.pathname === '/';
17
- const author = frontmatter.author ?? site.author;
18
- const date = frontmatter.date ? new Date(`${String(frontmatter.date).slice(0, 10)}T12:00:00Z`) : null;
19
- const dateLabel = date && !Number.isNaN(date.valueOf())
20
- ? new Intl.DateTimeFormat(site.language ?? 'en', { month: 'long', day: 'numeric', year: 'numeric', timeZone: 'UTC' }).format(date)
21
- : null;
17
+ const coverImage = frontmatter.image ?? frontmatter.ogImage;
18
+ const headings = (page.getHeadings?.() ?? []).filter(({ depth }) => depth === 2 || depth === 3);
19
+ const category = typeof frontmatter.category === 'string' ? frontmatter.category.trim() : '';
20
+ const date = postDate(frontmatter.date, site.language);
21
+ const postLayout = site.postLayout ?? {};
22
22
  ---
23
23
 
24
- <SiteLayout site={site} page={frontmatter}>
25
- <article class:list={['publication', { 'publication-home': home }]}>
26
- <header class="article-header">
27
- {!home && <a class="back-link" href="/">← All posts</a>}
28
- <p class="eyebrow">{home ? 'News, ideas & notes' : site.name}</p>
29
- <h1>{frontmatter.title ?? site.name}</h1>
30
- {frontmatter.description && <p class="description">{frontmatter.description}</p>}
31
- {!home && (author || dateLabel) && <p class="byline">
32
- {author && <span>{author}</span>}
33
- {dateLabel && <time datetime={String(frontmatter.date).slice(0, 10)}>{dateLabel}</time>}
34
- </p>}
24
+ <SiteLayout site={site} page={frontmatter} article>
25
+ <article class="publication publication-post" data-post-width={postLayout.contentWidth ?? 'standard'} data-post-hero={postLayout.heroLayout ?? 'split'}
26
+ data-post-cover={postLayout.showCover === false ? 'hidden' : 'visible'} data-post-toc={postLayout.showTableOfContents === false ? 'hidden' : 'visible'}>
27
+ <a class="back-link" href="/">← All posts</a>
28
+ <header class:list={['article-header', 'article-hero', { 'article-hero-with-image': coverImage }]}>
29
+ <div class="article-intro">
30
+ <p class="eyebrow">{category ? <a href={`/?category=${encodeURIComponent(category)}`}>{category}</a> : site.name}</p>
31
+ <h1>{frontmatter.title ?? site.name}</h1>
32
+ {frontmatter.description && <p class="description">{frontmatter.description}</p>}
33
+ <div class="byline">
34
+ {date.label && <time datetime={date.iso}>{date.label}</time>}
35
+ <AuthorByline post={frontmatter} site={site} />
36
+ </div>
37
+ </div>
38
+ {coverImage && <figure class="article-hero-image">
39
+ <img src={coverImage} alt={frontmatter.imageAlt ?? ''} width="1200" height="630" fetchpriority="high" />
40
+ </figure>}
35
41
  </header>
36
- {!home && frontmatter.image && <figure class="cover">
37
- <img src={frontmatter.image} alt={frontmatter.imageAlt ?? ''} width="1200" height="630" />
38
- </figure>}
39
- <div class="prose"><Content /></div>
42
+ <div class:list={['article-body', { 'article-body-with-toc': headings.length > 0 }]}>
43
+ {headings.length > 0 && <aside class="article-sidebar">
44
+ <nav class="article-toc" aria-label="Table of contents">
45
+ <h2>On this page</h2>
46
+ <ol>
47
+ {headings.map(({ depth, slug, text }) => <li class:list={{ 'toc-subheading': depth === 3 }}>
48
+ <a href={`#${slug}`}>{text}</a>
49
+ </li>)}
50
+ </ol>
51
+ </nav>
52
+ </aside>}
53
+ <div class="prose"><Content /></div>
54
+ </div>
40
55
  </article>
41
56
  </SiteLayout>
@@ -0,0 +1,10 @@
1
+ ---
2
+ import SiteLayout from '../layouts/SiteLayout.astro';
3
+ import BlogHome from '../components/BlogHome.astro';
4
+ import site from '../site.js';
5
+ const title = site.homeTitle ?? `${site.name} Blog`;
6
+ ---
7
+
8
+ <SiteLayout site={site} page={{ title }}>
9
+ <BlogHome site={site} />
10
+ </SiteLayout>