@lettercamp/cli 0.1.6 → 0.1.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/README.md +3 -1
- package/dist/account.js +189 -0
- package/dist/build.js +24 -9
- package/dist/cli.js +35 -5
- package/dist/code-block.js +50 -0
- package/dist/code-detection.js +24 -0
- package/dist/code-highlight.js +54 -0
- package/dist/custom-layout.js +32 -0
- package/dist/deploy.js +131 -0
- package/dist/dev.js +52 -15
- package/dist/domain.js +190 -0
- package/dist/hosting.js +132 -0
- package/dist/lettercamp.js +2 -2
- package/dist/media-dimensions.js +154 -0
- package/dist/new.js +1 -1
- package/dist/ports.js +31 -0
- package/dist/preview.js +48 -0
- package/dist/prompts.js +31 -0
- package/dist/publication-links.js +23 -0
- package/dist/react.js +15 -0
- package/dist/reading-time.js +25 -0
- package/dist/upload.js +57 -0
- package/dist/workspace.js +8 -3
- package/package.json +12 -2
- package/renderer/src/components/AuthorByline.astro +26 -0
- package/renderer/src/components/BlogHome.astro +37 -0
- package/renderer/src/components/Footer.astro +35 -0
- package/renderer/src/components/Header.astro +38 -0
- package/renderer/src/components/MobileMenuIcons.astro +7 -0
- package/renderer/src/components/PostCard.astro +25 -0
- package/renderer/src/components/SocialLink.astro +23 -0
- package/renderer/src/layouts/SiteLayout.astro +73 -27
- package/renderer/src/lib/analytics.ts +21 -0
- package/renderer/src/lib/author-dialog.ts +102 -0
- package/renderer/src/lib/authors.js +18 -0
- package/renderer/src/lib/block-widths.ts +30 -0
- package/renderer/src/lib/branding.js +15 -0
- package/renderer/src/lib/button.js +10 -0
- package/renderer/src/lib/category-routes.js +19 -0
- package/renderer/src/lib/code-block.ts +46 -0
- package/renderer/src/lib/fonts.js +33 -0
- package/renderer/src/lib/home-layout.ts +5 -0
- package/renderer/src/lib/image-radius.ts +5 -0
- package/renderer/src/lib/image-rounding.ts +28 -0
- package/renderer/src/lib/image-widths.ts +13 -0
- package/renderer/src/lib/metadata.js +47 -0
- package/renderer/src/lib/mobile-navigation.ts +76 -0
- package/renderer/src/lib/post-layout.ts +6 -0
- package/renderer/src/lib/post-share.ts +264 -0
- package/renderer/src/lib/post-toc.ts +69 -0
- package/renderer/src/lib/posts.js +40 -0
- package/renderer/src/lib/publication-url.js +14 -0
- package/renderer/src/lib/table-styles.ts +12 -0
- package/renderer/src/lib/urls.js +3 -0
- package/renderer/src/pages/[...slug].astro +50 -25
- package/renderer/src/pages/category/[tag].astro +16 -0
- package/renderer/src/pages/category/index.astro +4 -0
- package/renderer/src/pages/index.astro +10 -0
- package/renderer/src/styles/post-share.css +57 -0
- package/renderer/src/styles/site.css +352 -51
- package/renderer/src/layouts/Content.astro +0 -1
package/dist/preview.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { clearLine, cursorTo } from 'node:readline';
|
|
2
|
+
export async function preview(directory, options) {
|
|
3
|
+
console.log('\nWelcome to the Lettercamp CLI!\n');
|
|
4
|
+
const output = process.stdout;
|
|
5
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
6
|
+
let frame = 0;
|
|
7
|
+
const render = () => {
|
|
8
|
+
cursorTo(output, 0);
|
|
9
|
+
output.write(`${frames[frame++ % frames.length]} Starting your preview…`);
|
|
10
|
+
};
|
|
11
|
+
let timer;
|
|
12
|
+
if (output.isTTY && process.env.TERM !== 'dumb') {
|
|
13
|
+
render();
|
|
14
|
+
timer = setInterval(render, 80);
|
|
15
|
+
timer.unref();
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
console.log('Starting your preview…');
|
|
19
|
+
}
|
|
20
|
+
const finishLoading = () => {
|
|
21
|
+
if (!timer)
|
|
22
|
+
return;
|
|
23
|
+
clearInterval(timer);
|
|
24
|
+
timer = undefined;
|
|
25
|
+
cursorTo(output, 0);
|
|
26
|
+
clearLine(output, 0);
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
// Keep the welcome and animation ahead of the renderer's slower imports.
|
|
30
|
+
const { startDev } = await import('./dev.js');
|
|
31
|
+
const { server, contentRoot } = await startDev(directory, options);
|
|
32
|
+
finishLoading();
|
|
33
|
+
if (server.address.port !== options.port) {
|
|
34
|
+
console.log(`Port ${options.port} is in use. Using port ${server.address.port} instead.\n`);
|
|
35
|
+
}
|
|
36
|
+
console.log('Your publication is ready.\n');
|
|
37
|
+
for (const url of server.resolvedUrls.local)
|
|
38
|
+
console.log(` Local: ${url}`);
|
|
39
|
+
for (const url of server.resolvedUrls.network)
|
|
40
|
+
console.log(` Network: ${url}`);
|
|
41
|
+
console.log(`\nPreviewing ${contentRoot}`);
|
|
42
|
+
console.log('Changes to pages, components, styles, and settings.json reload automatically.');
|
|
43
|
+
console.log('Press Ctrl+C to stop.\n');
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
finishLoading();
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
export async function ask(label, fallback) {
|
|
3
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY)
|
|
4
|
+
throw new Error('This step needs a terminal. Run this command interactively once to finish setup.');
|
|
5
|
+
const readline = createInterface({ input: process.stdin, output: process.stderr });
|
|
6
|
+
const abort = new AbortController();
|
|
7
|
+
readline.on('SIGINT', () => abort.abort());
|
|
8
|
+
try {
|
|
9
|
+
const value = (await readline.question(`${label}${fallback ? ` (${fallback})` : ''}: `, { signal: abort.signal })).trim();
|
|
10
|
+
return value || fallback || '';
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error('Cancelled.');
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
readline.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function confirm(label) {
|
|
20
|
+
return /^(y|yes)$/i.test(await ask(`${label} [y/N]`));
|
|
21
|
+
}
|
|
22
|
+
export async function choose(label, choices) {
|
|
23
|
+
console.error(`\n${label}`);
|
|
24
|
+
choices.forEach((choice, i) => console.error(` ${i + 1}. ${choice}`));
|
|
25
|
+
for (;;) {
|
|
26
|
+
const value = await ask('Choose a number', '1');
|
|
27
|
+
if (/^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= choices.length)
|
|
28
|
+
return Number(value) - 1;
|
|
29
|
+
console.error(`Choose a number from 1 to ${choices.length}.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Root-relative links in Markdown and literal MDX attributes belong to the publication.
|
|
2
|
+
// Absolute URLs and anchors retain their authored meaning.
|
|
3
|
+
export function publicationLinks(options) {
|
|
4
|
+
const base = options.basePath.replace(/\/+$/, '');
|
|
5
|
+
const prefix = (value) => value.startsWith('/') && !value.startsWith('//') ? base + value : value;
|
|
6
|
+
return (tree) => {
|
|
7
|
+
if (!base)
|
|
8
|
+
return;
|
|
9
|
+
function visit(node) {
|
|
10
|
+
if (['link', 'image', 'definition'].includes(node.type) && node.url)
|
|
11
|
+
node.url = prefix(node.url);
|
|
12
|
+
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
|
|
13
|
+
for (const attribute of node.attributes ?? []) {
|
|
14
|
+
if (attribute.type === 'mdxJsxAttribute' && ['href', 'src', 'poster'].includes(attribute.name ?? '') && typeof attribute.value === 'string')
|
|
15
|
+
attribute.value = prefix(attribute.value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const child of node.children ?? [])
|
|
19
|
+
visit(child);
|
|
20
|
+
}
|
|
21
|
+
visit(tree);
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
export const reactIntegrationDirectory = path.dirname(require.resolve('@astrojs/react/package.json'));
|
|
5
|
+
// Astro resolves renderer entrypoints from the temporary workspace. Locate the
|
|
6
|
+
// CLI's integration even when npm puts it in a nested node_modules folder.
|
|
7
|
+
// The integration deduplicates React against the workspace's CLI dependencies.
|
|
8
|
+
export const reactViteConfig = {
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: Object.keys(require('@astrojs/react/package.json').exports).map(entry => {
|
|
11
|
+
const id = '@astrojs/react' + (entry === '.' ? '' : entry.slice(1));
|
|
12
|
+
return { find: new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), replacement: require.resolve(id) };
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Runs on parsed Markdown/MDX, so frontmatter, imports, URLs, and JSX
|
|
2
|
+
// attributes never count as prose. Runtime-generated component text is unknown.
|
|
3
|
+
export function readingTime() {
|
|
4
|
+
return (tree, file) => {
|
|
5
|
+
const text = [];
|
|
6
|
+
function visit(node) {
|
|
7
|
+
if (node.type === 'text' || node.type === 'inlineCode')
|
|
8
|
+
text.push(node.value ?? '');
|
|
9
|
+
for (const child of node.children ?? [])
|
|
10
|
+
visit(child);
|
|
11
|
+
}
|
|
12
|
+
visit(tree);
|
|
13
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
14
|
+
let words = 0;
|
|
15
|
+
for (const segment of segmenter.segment(text.join(' '))) {
|
|
16
|
+
if (segment.isWordLike)
|
|
17
|
+
words++;
|
|
18
|
+
}
|
|
19
|
+
const astro = file.data.astro;
|
|
20
|
+
if (astro?.frontmatter) {
|
|
21
|
+
// Always overwrite frontmatter: this is derived content, not an override.
|
|
22
|
+
astro.frontmatter.readingMinutes = Math.max(1, Math.ceil(words / 200));
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { open } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { apiRequest, credentials, responseJson, selectUploadSite } from './account.js';
|
|
4
|
+
async function uploadFile(file, slug, token) {
|
|
5
|
+
const handle = await open(file, 'r');
|
|
6
|
+
let bytes;
|
|
7
|
+
try {
|
|
8
|
+
const info = await handle.stat();
|
|
9
|
+
if (!info.isFile() || !info.size)
|
|
10
|
+
throw new Error('Choose a nonempty image or font file.');
|
|
11
|
+
if (info.size > 10 * 1024 * 1024)
|
|
12
|
+
throw new Error('Files must be 10 MB or smaller.');
|
|
13
|
+
bytes = await handle.readFile();
|
|
14
|
+
if (bytes.byteLength > 10 * 1024 * 1024)
|
|
15
|
+
throw new Error('Files must be 10 MB or smaller.');
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
await handle.close();
|
|
19
|
+
}
|
|
20
|
+
const font = /\.(ttf|otf|woff2?)$/i.test(file);
|
|
21
|
+
return responseJson(await apiRequest(`/api/sites/${encodeURIComponent(slug)}/assets${font ? "?type=font" : ""}`, {
|
|
22
|
+
method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-File-Name': encodeURIComponent(path.basename(file)) }, body: new Uint8Array(bytes),
|
|
23
|
+
}, token));
|
|
24
|
+
}
|
|
25
|
+
export async function upload(files, options) {
|
|
26
|
+
const saved = await credentials();
|
|
27
|
+
const { site, remember } = await selectUploadSite(saved.token, options.site);
|
|
28
|
+
const multiple = files.length > 1;
|
|
29
|
+
const results = [];
|
|
30
|
+
let remembered = false;
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
let result;
|
|
33
|
+
try {
|
|
34
|
+
result = await uploadFile(file, site.slug, saved.token);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (!multiple)
|
|
38
|
+
throw error;
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
results.push({ file, error: message });
|
|
41
|
+
console.error(`${file}: ${message}`);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
results.push({ file, ...result });
|
|
46
|
+
if (!remembered) {
|
|
47
|
+
remembered = true;
|
|
48
|
+
await remember().catch(() => console.error('Uploaded successfully, but the site selection could not be saved. Use --site on your next upload.'));
|
|
49
|
+
}
|
|
50
|
+
if (!options.json)
|
|
51
|
+
console.log(multiple ? `${file}\t${result.url}` : result.url);
|
|
52
|
+
else if (!multiple)
|
|
53
|
+
console.log(JSON.stringify(result));
|
|
54
|
+
}
|
|
55
|
+
if (options.json && multiple)
|
|
56
|
+
console.log(JSON.stringify(results));
|
|
57
|
+
}
|
package/dist/workspace.js
CHANGED
|
@@ -4,16 +4,18 @@ 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 { writeCustomLayout } from './custom-layout.js';
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const renderer = fileURLToPath(new URL('../renderer/', import.meta.url));
|
|
9
10
|
export async function prepareWorkspace(directory) {
|
|
10
11
|
const contentRoot = await realpath(directory);
|
|
11
|
-
JSON.parse(await readFile(path.join(contentRoot, '
|
|
12
|
+
JSON.parse(await readFile(path.join(contentRoot, 'settings.json'), 'utf8'));
|
|
12
13
|
if (!(await stat(path.join(contentRoot, 'pages'))).isDirectory())
|
|
13
14
|
throw new Error('Missing pages directory.');
|
|
14
15
|
const workspace = await realpath(await mkdtemp(path.join(tmpdir(), 'lettercamp-')));
|
|
15
16
|
try {
|
|
16
17
|
await cp(renderer, workspace, { recursive: true });
|
|
18
|
+
await writeCustomLayout(workspace, contentRoot);
|
|
17
19
|
await writeFile(path.join(workspace, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
|
18
20
|
// Dependencies stay in the CLI installation; the glob uses real source paths
|
|
19
21
|
// so Vite sees additions and deletions without a symlink/realpath mismatch.
|
|
@@ -21,8 +23,11 @@ export async function prepareWorkspace(directory) {
|
|
|
21
23
|
await symlink(modules, path.join(workspace, 'node_modules'), 'junction');
|
|
22
24
|
const contentPrefix = path.relative(path.join(workspace, 'src'), path.join(contentRoot, 'pages')).replaceAll('\\', '/') + '/';
|
|
23
25
|
await writeFile(path.join(workspace, 'src', 'content.js'), `export const contentPrefix = ${JSON.stringify(contentPrefix)};\n` +
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
+
`const allPages = import.meta.glob(${JSON.stringify(escapePath(contentPrefix) + '*.{md,mdx}')}, { eager: true });\n` +
|
|
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'), `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`);
|
|
26
31
|
return { workspace, contentRoot, modules, cleanup: () => rm(workspace, { recursive: true, force: true, maxRetries: 3 }) };
|
|
27
32
|
}
|
|
28
33
|
catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lettercamp/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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.
|
|
29
|
+
"node": ">=22.22.0"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "tsc",
|
|
@@ -38,10 +38,20 @@
|
|
|
38
38
|
"registry": "https://registry.npmjs.org/"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
+
"@astrojs/markdown-remark": "^7.3.3",
|
|
41
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",
|
|
42
46
|
"astro": "7.3.8",
|
|
43
47
|
"commander": "^15.0.0",
|
|
44
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",
|
|
45
55
|
"semver": "^7.7.3",
|
|
46
56
|
"tinyglobby": "^0.2.17"
|
|
47
57
|
},
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
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
|
+
---
|
|
8
|
+
|
|
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>
|
|
24
|
+
</span>}
|
|
25
|
+
{showReadingTime && <span class="reading-time">{post.readingMinutes} min read</span>}
|
|
26
|
+
</div>}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { sitePath } from '../lib/urls.js';
|
|
3
|
+
import PostCard from './PostCard.astro';
|
|
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;
|
|
11
|
+
const featuredLimit = 9;
|
|
12
|
+
---
|
|
13
|
+
|
|
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` }}>
|
|
15
|
+
<header class="blog-heading">
|
|
16
|
+
<h1>{category || (site.home.title ?? `${site.name} Blog`)}</h1>
|
|
17
|
+
{site.description && <p class="description" style="margin-top: 12px">{site.description}</p>}
|
|
18
|
+
</header>
|
|
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>)}
|
|
22
|
+
</nav>}
|
|
23
|
+
<p class="sr-only" data-results-count role="status" aria-live="polite">{posts.length} articles</p>
|
|
24
|
+
<section aria-label="Recent articles">
|
|
25
|
+
<h2 class="sr-only">Recent articles</h2>
|
|
26
|
+
<div class="post-list" data-featured-posts>
|
|
27
|
+
{posts.slice(0, featuredLimit).map(post => <PostCard post={post} site={site} />)}
|
|
28
|
+
</div>
|
|
29
|
+
<p class="empty-state" data-empty-state hidden={posts.length > 0}>No articles here yet.</p>
|
|
30
|
+
</section>
|
|
31
|
+
<section class="archive" aria-labelledby="archive-heading" data-archive hidden={posts.length <= featuredLimit}>
|
|
32
|
+
<h2 id="archive-heading">Archive</h2>
|
|
33
|
+
<div class="archive-list" data-archive-posts>
|
|
34
|
+
{posts.slice(featuredLimit).map(post => <PostCard post={post} site={site} />)}
|
|
35
|
+
</div>
|
|
36
|
+
</section>
|
|
37
|
+
</div>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { sitePath } from '../lib/urls.js';
|
|
3
|
+
import SocialLink from './SocialLink.astro';
|
|
4
|
+
const { site } = Astro.props;
|
|
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
|
+
});
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<footer class="site-footer">
|
|
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)}`}>
|
|
15
|
+
<div class="footer-main">
|
|
16
|
+
<div class="footer-brand">
|
|
17
|
+
<a class="brand" href={sitePath("/")} aria-label={`${site.name} home`}>
|
|
18
|
+
<img src={logo} width="24" height="24" alt="" />
|
|
19
|
+
</a>
|
|
20
|
+
</div>
|
|
21
|
+
{columns.length > 0 && <div class="footer-columns">
|
|
22
|
+
{columns.map((column, index) => <nav class="footer-column" aria-label={column.header ?? `Footer links ${index + 1}`}>
|
|
23
|
+
{column.header && <h2>{column.header}</h2>}
|
|
24
|
+
<ul>{(column.items ?? []).map(item => <li><a href={sitePath(item.href)}>{item.label}</a></li>)}</ul>
|
|
25
|
+
</nav>)}
|
|
26
|
+
</div>}
|
|
27
|
+
</div>
|
|
28
|
+
<div class="footer-bottom">
|
|
29
|
+
{socials.length > 0 && <nav class="footer-socials" aria-label="Social links">
|
|
30
|
+
{socials.map(({ platform, href }) => <SocialLink name={platform} href={href} />)}
|
|
31
|
+
</nav>}
|
|
32
|
+
<a class="footer-credit" href="https://lettercamp.com">Made with <strong>Lettercamp</strong></a>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
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,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
import AuthorByline from './AuthorByline.astro';
|
|
3
|
+
const { post, site } = Astro.props;
|
|
4
|
+
const image = post.ogImage ?? post.image ?? site.seo?.ogImage;
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
<article class="post-card" data-post-category={post.category}>
|
|
8
|
+
<div class="post-card-layout">
|
|
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
|
+
{(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>
|
|
18
|
+
{post.description && <p class="post-card-description">{post.description}</p>}
|
|
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>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
</article>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
const { name, href } = Astro.props;
|
|
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' };
|
|
5
|
+
const label = names[platform] ?? name.replaceAll('-', ' ').replace(/^./, character => character.toUpperCase());
|
|
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
|
+
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} rel="me noopener noreferrer" target="_blank">
|
|
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,43 +1,89 @@
|
|
|
1
1
|
---
|
|
2
2
|
import '../styles/site.css';
|
|
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';
|
|
3
20
|
|
|
4
|
-
const { site, page } = Astro.props;
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
21
|
+
const { site, page, article = false } = Astro.props;
|
|
22
|
+
const meta = pageMetadata(site, page, Astro.url, article, basePath);
|
|
23
|
+
const typography = site.design?.typography ?? {};
|
|
24
|
+
const fonts = fontResources(typography);
|
|
25
|
+
const fontSize = (value, fallback) => typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback;
|
|
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);
|
|
9
30
|
---
|
|
10
31
|
|
|
11
32
|
<!doctype html>
|
|
12
|
-
<html lang={
|
|
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 }}>
|
|
13
34
|
<head>
|
|
14
35
|
<meta charset="utf-8" />
|
|
15
36
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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} />
|
|
20
54
|
<meta property="og:site_name" content={site.name} />
|
|
21
|
-
<meta property="og:type" content={
|
|
22
|
-
<meta property="og:url" content={
|
|
23
|
-
{
|
|
24
|
-
<meta
|
|
25
|
-
{
|
|
55
|
+
<meta property="og:type" content={article ? 'article' : 'website'} />
|
|
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} />}
|
|
26
77
|
</head>
|
|
27
78
|
<body>
|
|
28
79
|
<a class="skip-link" href="#content">Skip to content</a>
|
|
29
|
-
<
|
|
30
|
-
<a class="brand" href="/">
|
|
31
|
-
{site.favicon && <img src={site.favicon} width="30" height="30" alt="" />}
|
|
32
|
-
<span>{site.name}</span>
|
|
33
|
-
</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>
|
|
39
|
-
</header>
|
|
80
|
+
<Header site={site} pathname={Astro.url.pathname} />
|
|
40
81
|
<main id="content"><slot /></main>
|
|
41
|
-
<
|
|
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} />
|
|
42
88
|
</body>
|
|
43
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
|
+
}
|