@lettercamp/cli 0.0.0-stage → 0.1.0
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 +4 -2
- package/bin/lettercamp.js +3 -0
- package/dist/build.js +38 -0
- package/dist/cli.js +45 -0
- package/dist/dev.js +72 -0
- package/dist/lettercamp.js +13 -0
- package/dist/new.js +43 -0
- package/dist/version.js +59 -0
- package/dist/workspace.js +32 -0
- package/package.json +49 -4
- package/renderer/src/layouts/Content.astro +1 -0
- package/renderer/src/layouts/SiteLayout.astro +43 -0
- package/renderer/src/pages/[...slug].astro +41 -0
- package/renderer/src/styles/site.css +70 -0
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Lettercamp CLI implementation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Astro renderer and commands behind Lettercamp. Most users should install the public wrapper with `npm install -g lettercamp`, then run `lettercamp dev` inside a publication.
|
|
4
|
+
|
|
5
|
+
Requires Node.js 22.19 or newer. See the [repository](https://github.com/lettercamp/cli) for development and publishing instructions.
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { build } from 'astro';
|
|
3
|
+
import mdx from '@astrojs/mdx';
|
|
4
|
+
import { prepareWorkspace } from './workspace.js';
|
|
5
|
+
export async function buildSite(directory, options) {
|
|
6
|
+
const origin = new URL(options.site);
|
|
7
|
+
if (!['https:', 'http:'].includes(origin.protocol))
|
|
8
|
+
throw new Error('Use an HTTP or HTTPS site URL.');
|
|
9
|
+
const root = path.resolve(directory);
|
|
10
|
+
const outDir = path.resolve(root, options.outDir ?? 'dist');
|
|
11
|
+
const overlaps = (a, b) => a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep);
|
|
12
|
+
if (outDir === root || root.startsWith(outDir + path.sep) || ['pages', 'public'].some(name => overlaps(outDir, path.join(root, name)))) {
|
|
13
|
+
throw new Error('Output must not replace the publication or its source files.');
|
|
14
|
+
}
|
|
15
|
+
const { workspace, contentRoot, cleanup } = await prepareWorkspace(root);
|
|
16
|
+
const previousCwd = process.cwd();
|
|
17
|
+
try {
|
|
18
|
+
// Astro 7 places temporary prerender modules relative to cwd when outDir
|
|
19
|
+
// is external. Keep them beside the renderer's node_modules, not the content.
|
|
20
|
+
process.chdir(workspace);
|
|
21
|
+
await build({
|
|
22
|
+
root: workspace, configFile: false, site: origin.origin,
|
|
23
|
+
cacheDir: path.join(workspace, '.cache'),
|
|
24
|
+
publicDir: path.join(contentRoot, 'public'), outDir, output: 'static',
|
|
25
|
+
integrations: [mdx()],
|
|
26
|
+
build: { format: 'directory', ...(options.assetsPrefix ? { assetsPrefix: options.assetsPrefix } : {}) },
|
|
27
|
+
vite: { resolve: { alias: {
|
|
28
|
+
home: path.join(workspace, 'src/layouts/Content.astro'),
|
|
29
|
+
post: path.join(workspace, 'src/layouts/Content.astro'),
|
|
30
|
+
} } },
|
|
31
|
+
});
|
|
32
|
+
return outDir;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
process.chdir(previousCwd);
|
|
36
|
+
await cleanup();
|
|
37
|
+
}
|
|
38
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Command, InvalidArgumentError } from 'commander';
|
|
2
|
+
import { version, showVersion, update } from './version.js';
|
|
3
|
+
export async function run(argv = process.argv) {
|
|
4
|
+
const program = new Command()
|
|
5
|
+
.name('lettercamp')
|
|
6
|
+
.description('Create and preview your Lettercamp publication.')
|
|
7
|
+
.version(version, '-v, --version', 'Show the installed CLI version');
|
|
8
|
+
program.command('new')
|
|
9
|
+
.description('Download the starter into a new or empty directory')
|
|
10
|
+
.argument('[directory]', 'Destination directory', 'my-lettercamp')
|
|
11
|
+
.action(async (directory) => {
|
|
12
|
+
const { createSite } = await import('./new.js');
|
|
13
|
+
await createSite(directory);
|
|
14
|
+
});
|
|
15
|
+
program.command('version')
|
|
16
|
+
.description('Show the installed and latest published CLI versions')
|
|
17
|
+
.action(showVersion);
|
|
18
|
+
program.command('update')
|
|
19
|
+
.description('Update the global npm installation if a newer version exists')
|
|
20
|
+
.action(update);
|
|
21
|
+
program.command('dev')
|
|
22
|
+
.description('Preview the current publication with live reload')
|
|
23
|
+
.option('-p, --port <number>', 'Local port', (value) => {
|
|
24
|
+
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) {
|
|
25
|
+
throw new InvalidArgumentError('Use a port between 1 and 65535.');
|
|
26
|
+
}
|
|
27
|
+
return Number(value);
|
|
28
|
+
}, 3000)
|
|
29
|
+
.option('--host <address>', 'Host to listen on', 'localhost')
|
|
30
|
+
.option('--open', 'Open the preview in your browser', false)
|
|
31
|
+
.action(async (options) => {
|
|
32
|
+
const { startDev } = await import('./dev.js');
|
|
33
|
+
await startDev(process.cwd(), options);
|
|
34
|
+
});
|
|
35
|
+
program.command('build')
|
|
36
|
+
.description('Build a static publication for hosting')
|
|
37
|
+
.requiredOption('--site <url>', 'Public site URL for metadata')
|
|
38
|
+
.option('--out-dir <directory>', 'Output directory', 'dist')
|
|
39
|
+
.option('--assets-prefix <url>', 'Base URL for versioned generated assets')
|
|
40
|
+
.action(async (options) => {
|
|
41
|
+
const { buildSite } = await import('./build.js');
|
|
42
|
+
await buildSite(process.cwd(), options);
|
|
43
|
+
});
|
|
44
|
+
await program.parseAsync(argv);
|
|
45
|
+
}
|
package/dist/dev.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { dev } from 'astro';
|
|
4
|
+
import mdx from '@astrojs/mdx';
|
|
5
|
+
import { prepareWorkspace } from './workspace.js';
|
|
6
|
+
export async function startDev(directory, options) {
|
|
7
|
+
const { workspace, contentRoot, modules } = await prepareWorkspace(directory);
|
|
8
|
+
let server;
|
|
9
|
+
let closeVite;
|
|
10
|
+
let stopping = false;
|
|
11
|
+
const cleanup = async () => {
|
|
12
|
+
if (stopping)
|
|
13
|
+
return;
|
|
14
|
+
stopping = true;
|
|
15
|
+
process.off('SIGINT', stop);
|
|
16
|
+
process.off('SIGTERM', stop);
|
|
17
|
+
try {
|
|
18
|
+
if (server)
|
|
19
|
+
await server.stop();
|
|
20
|
+
else
|
|
21
|
+
await closeVite?.();
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
await rm(workspace, { recursive: true, force: true, maxRetries: 3 });
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const stop = () => {
|
|
28
|
+
cleanup().catch((error) => {
|
|
29
|
+
console.error(`Unable to clean up preview: ${error.message}`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
try {
|
|
34
|
+
server = await dev({
|
|
35
|
+
root: workspace,
|
|
36
|
+
configFile: false,
|
|
37
|
+
publicDir: path.join(contentRoot, 'public'),
|
|
38
|
+
output: 'static',
|
|
39
|
+
integrations: [mdx()],
|
|
40
|
+
devToolbar: { enabled: false },
|
|
41
|
+
server: { port: options.port, host: options.host, open: options.open },
|
|
42
|
+
vite: {
|
|
43
|
+
plugins: [{
|
|
44
|
+
name: 'lettercamp-content',
|
|
45
|
+
configureServer(vite) {
|
|
46
|
+
closeVite = () => vite.close();
|
|
47
|
+
// Imported files are watched automatically, but external folders
|
|
48
|
+
// must be registered for newly created pages to be discovered.
|
|
49
|
+
vite.watcher.add([path.join(contentRoot, 'pages'), path.join(contentRoot, 'public'), path.join(contentRoot, 'site.json')]);
|
|
50
|
+
},
|
|
51
|
+
}],
|
|
52
|
+
// Astro interprets frontmatter.layout as an import. The publication's
|
|
53
|
+
// home/post names are presentation hints handled by our outer page.
|
|
54
|
+
resolve: {
|
|
55
|
+
alias: {
|
|
56
|
+
home: path.join(workspace, 'src/layouts/Content.astro'),
|
|
57
|
+
post: path.join(workspace, 'src/layouts/Content.astro'),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
server: { strictPort: true, fs: { allow: [workspace, contentRoot, modules] } },
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
process.once('SIGINT', stop);
|
|
64
|
+
process.once('SIGTERM', stop);
|
|
65
|
+
console.log(`\nPreviewing ${contentRoot}\nChanges to pages, images, and site.json reload automatically. Press Ctrl+C to stop.\n`);
|
|
66
|
+
return { stop: cleanup, server };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
await cleanup();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import semver from 'semver';
|
|
3
|
+
try {
|
|
4
|
+
if (!semver.satisfies(process.versions.node, '>=22.19.0')) {
|
|
5
|
+
throw new Error(`Node.js 22.19 or newer is required (you have ${process.versions.node}).`);
|
|
6
|
+
}
|
|
7
|
+
const { run } = await import('./cli.js');
|
|
8
|
+
await run();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
console.error(`\nLettercamp: ${error instanceof Error ? error.message : String(error)}`);
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
}
|
package/dist/new.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { cp, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { downloadTemplate } from 'giget';
|
|
5
|
+
async function ensureEmpty(directory) {
|
|
6
|
+
let entries;
|
|
7
|
+
try {
|
|
8
|
+
entries = await readdir(directory);
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT')
|
|
12
|
+
return;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
if (entries.length)
|
|
16
|
+
throw new Error(`Directory is not empty: ${directory}. Choose a new or empty directory.`);
|
|
17
|
+
}
|
|
18
|
+
export async function createSite(directory) {
|
|
19
|
+
const destination = path.resolve(directory);
|
|
20
|
+
await ensureEmpty(destination);
|
|
21
|
+
const temporary = await mkdtemp(path.join(tmpdir(), 'lettercamp-new-'));
|
|
22
|
+
try {
|
|
23
|
+
console.log('Downloading the Lettercamp starter…');
|
|
24
|
+
await downloadTemplate('github:lettercamp/starter#main', {
|
|
25
|
+
dir: temporary,
|
|
26
|
+
force: true,
|
|
27
|
+
preferOffline: false,
|
|
28
|
+
});
|
|
29
|
+
// Download first, so a failed request does not leave a half-created site.
|
|
30
|
+
await ensureEmpty(destination);
|
|
31
|
+
await mkdir(destination, { recursive: true });
|
|
32
|
+
for (const entry of await readdir(temporary)) {
|
|
33
|
+
await cp(path.join(temporary, entry), path.join(destination, entry), {
|
|
34
|
+
recursive: true, force: false, errorOnExist: true,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
console.log(`\nCreated your publication at ${destination}`);
|
|
38
|
+
console.log('Open that directory and run: lettercamp dev');
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await rm(temporary, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import semver from 'semver';
|
|
4
|
+
const metadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
|
|
5
|
+
export const version = metadata.version;
|
|
6
|
+
async function latestVersion() {
|
|
7
|
+
const response = await fetch('https://registry.npmjs.org/lettercamp/latest', {
|
|
8
|
+
signal: AbortSignal.timeout(10_000),
|
|
9
|
+
headers: { Accept: 'application/json' },
|
|
10
|
+
});
|
|
11
|
+
if (response.status === 404)
|
|
12
|
+
return null;
|
|
13
|
+
if (!response.ok)
|
|
14
|
+
throw new Error(`Unable to check npm for updates (HTTP ${response.status}).`);
|
|
15
|
+
const { version: latest } = await response.json();
|
|
16
|
+
if (!semver.valid(latest))
|
|
17
|
+
throw new Error('npm did not return a valid release version.');
|
|
18
|
+
return latest;
|
|
19
|
+
}
|
|
20
|
+
export async function showVersion() {
|
|
21
|
+
console.log(`Installed: ${version}`);
|
|
22
|
+
try {
|
|
23
|
+
const latest = await latestVersion();
|
|
24
|
+
console.log(latest ? `Latest: ${latest}` : 'Latest: not published to npm yet');
|
|
25
|
+
if (latest && semver.gt(latest, version))
|
|
26
|
+
console.log('Run lettercamp update to install it.');
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
console.error(`Latest: unavailable (${error instanceof Error ? error.message : String(error)})`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export async function update() {
|
|
34
|
+
const latest = await latestVersion();
|
|
35
|
+
if (!latest) {
|
|
36
|
+
console.log('Lettercamp has not been published to npm yet. No update is available.');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (!semver.gt(latest, version)) {
|
|
40
|
+
console.log(`Lettercamp ${version} is up to date (latest: ${latest}).`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
console.log(`Updating Lettercamp ${version} → ${latest} globally with npm…`);
|
|
44
|
+
await new Promise((resolve, reject) => {
|
|
45
|
+
const child = spawn('npm', ['install', '--global', `lettercamp@${latest}`], {
|
|
46
|
+
stdio: 'inherit',
|
|
47
|
+
// Windows distributes npm as a .cmd shim. Arguments are fixed or semver-validated.
|
|
48
|
+
shell: process.platform === 'win32',
|
|
49
|
+
});
|
|
50
|
+
child.once('error', reject);
|
|
51
|
+
child.once('exit', (code, signal) => {
|
|
52
|
+
if (code === 0)
|
|
53
|
+
resolve();
|
|
54
|
+
else
|
|
55
|
+
reject(new Error(`npm update failed (${signal ?? code}). Check npm's output and your global install permissions.`));
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
console.log(`Updated to Lettercamp ${latest}.`);
|
|
59
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cp, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { escapePath } from 'tinyglobby';
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const renderer = fileURLToPath(new URL('../renderer/', import.meta.url));
|
|
9
|
+
export async function prepareWorkspace(directory) {
|
|
10
|
+
const contentRoot = await realpath(directory);
|
|
11
|
+
JSON.parse(await readFile(path.join(contentRoot, 'site.json'), 'utf8'));
|
|
12
|
+
if (!(await stat(path.join(contentRoot, 'pages'))).isDirectory())
|
|
13
|
+
throw new Error('Missing pages directory.');
|
|
14
|
+
const workspace = await realpath(await mkdtemp(path.join(tmpdir(), 'lettercamp-')));
|
|
15
|
+
try {
|
|
16
|
+
await cp(renderer, workspace, { recursive: true });
|
|
17
|
+
await writeFile(path.join(workspace, 'package.json'), JSON.stringify({ private: true, type: 'module' }));
|
|
18
|
+
// Dependencies stay in the CLI installation; the glob uses real source paths
|
|
19
|
+
// so Vite sees additions and deletions without a symlink/realpath mismatch.
|
|
20
|
+
const modules = path.dirname(path.dirname(require.resolve('astro/package.json')));
|
|
21
|
+
await symlink(modules, path.join(workspace, 'node_modules'), 'junction');
|
|
22
|
+
const contentPrefix = path.relative(path.join(workspace, 'src'), path.join(contentRoot, 'pages')).replaceAll('\\', '/') + '/';
|
|
23
|
+
await writeFile(path.join(workspace, 'src', 'content.js'), `export const contentPrefix = ${JSON.stringify(contentPrefix)};\n` +
|
|
24
|
+
`export const pages = import.meta.glob(${JSON.stringify(escapePath(contentPrefix) + '**/*.{md,mdx}')}, { eager: true });\n`);
|
|
25
|
+
await writeFile(path.join(workspace, 'src', 'site.js'), `export { default } from ${JSON.stringify(path.join(contentRoot, 'site.json').replaceAll('\\', '/'))};\n`);
|
|
26
|
+
return { workspace, contentRoot, modules, cleanup: () => rm(workspace, { recursive: true, force: true, maxRetries: 3 }) };
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
await rm(workspace, { recursive: true, force: true });
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lettercamp/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create and preview Lettercamp publications powered by MDX and Astro.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/lettercamp/cli.git",
|
|
8
|
+
"directory": "core"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/lettercamp/cli#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/lettercamp/cli/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"lettercamp": "bin/lettercamp.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"dist",
|
|
21
|
+
"renderer"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.19.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc",
|
|
28
|
+
"check": "tsc --noEmit",
|
|
29
|
+
"prepack": "npm run build"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"registry": "https://registry.npmjs.org/"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@astrojs/mdx": "8.0.3",
|
|
37
|
+
"astro": "7.3.8",
|
|
38
|
+
"commander": "^15.0.0",
|
|
39
|
+
"giget": "^3.3.1",
|
|
40
|
+
"semver": "^7.7.3",
|
|
41
|
+
"tinyglobby": "^0.2.17"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.20.5",
|
|
45
|
+
"@types/semver": "^7.8.0",
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
|
+
},
|
|
48
|
+
"exports": {
|
|
49
|
+
"./cli": "./dist/lettercamp.js"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<slot />
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
import '../styles/site.css';
|
|
3
|
+
|
|
4
|
+
const { site, page } = Astro.props;
|
|
5
|
+
const title = page.title ?? site.name;
|
|
6
|
+
const description = page.description ?? site.description;
|
|
7
|
+
const image = page.ogImage ?? page.image ?? site.ogImage;
|
|
8
|
+
const imageUrl = image ? new URL(image, Astro.url).href : undefined;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<!doctype html>
|
|
12
|
+
<html lang={site.language ?? 'en'}>
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="utf-8" />
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
16
|
+
<title>{title === site.name ? title : `${title} · ${site.name}`}</title>
|
|
17
|
+
<meta name="description" content={description} />
|
|
18
|
+
<meta property="og:title" content={title} />
|
|
19
|
+
<meta property="og:description" content={description} />
|
|
20
|
+
<meta property="og:site_name" content={site.name} />
|
|
21
|
+
<meta property="og:type" content={page.layout === 'post' ? 'article' : 'website'} />
|
|
22
|
+
<meta property="og:url" content={Astro.url.href} />
|
|
23
|
+
{imageUrl && <meta property="og:image" content={imageUrl} />}
|
|
24
|
+
<meta name="twitter:card" content={imageUrl ? 'summary_large_image' : 'summary'} />
|
|
25
|
+
{site.favicon && <link rel="icon" href={site.favicon} />}
|
|
26
|
+
</head>
|
|
27
|
+
<body>
|
|
28
|
+
<a class="skip-link" href="#content">Skip to content</a>
|
|
29
|
+
<header class="site-header">
|
|
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>
|
|
40
|
+
<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>
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
import SiteLayout from '../layouts/SiteLayout.astro';
|
|
3
|
+
import site from '../site.js';
|
|
4
|
+
import { pages, contentPrefix } from '../content.js';
|
|
5
|
+
|
|
6
|
+
export function getStaticPaths() {
|
|
7
|
+
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 } };
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { page } = Astro.props;
|
|
15
|
+
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;
|
|
22
|
+
---
|
|
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>}
|
|
35
|
+
</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>
|
|
40
|
+
</article>
|
|
41
|
+
</SiteLayout>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
4
|
+
color: #20201e;
|
|
5
|
+
background: #fffefa;
|
|
6
|
+
font-synthesis: none;
|
|
7
|
+
-webkit-font-smoothing: antialiased;
|
|
8
|
+
}
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body { margin: 0; }
|
|
11
|
+
a { color: inherit; text-underline-offset: .2em; }
|
|
12
|
+
a:focus-visible { outline: 2px solid #235b45; outline-offset: 5px; border-radius: 2px; }
|
|
13
|
+
img { display: block; max-width: 100%; height: auto; }
|
|
14
|
+
.site-header, .site-footer { width: min(1080px, calc(100% - 48px)); margin: auto; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
|
15
|
+
.site-header { min-height: 88px; }
|
|
16
|
+
.brand { display: flex; align-items: center; gap: 8px; font-size: 21px; font-weight: 650; letter-spacing: -.6px; text-decoration: none; }
|
|
17
|
+
nav { display: flex; flex-wrap: wrap; gap: 24px; font-size: 14px; }
|
|
18
|
+
nav a { text-decoration: none; color: #66655f; }
|
|
19
|
+
nav a[aria-current], nav a:hover { color: #20201e; }
|
|
20
|
+
.publication { width: min(760px, calc(100% - 48px)); margin: 56px auto 96px; }
|
|
21
|
+
.publication-home { width: min(880px, calc(100% - 48px)); }
|
|
22
|
+
.article-header { margin-bottom: 38px; }
|
|
23
|
+
.back-link { display: inline-block; font-size: 14px; color: #77746e; text-decoration: none; margin-bottom: 40px; }
|
|
24
|
+
.eyebrow { text-transform: uppercase; letter-spacing: .16em; font-size: 11px; font-weight: 600; color: #77746e; margin: 0 0 20px; }
|
|
25
|
+
h1, h2, h3, h4 { text-wrap: balance; }
|
|
26
|
+
h1 { font: 500 clamp(38px, 5.8vw, 62px)/1.08 Georgia, serif; letter-spacing: -.045em; margin: 0 0 22px; }
|
|
27
|
+
.publication-home .article-header { text-align: center; margin: 12px auto 56px; max-width: 700px; }
|
|
28
|
+
.description { color: #6b6962; font-size: 19px; line-height: 1.6; max-width: 650px; margin: 0; }
|
|
29
|
+
.publication-home .description { margin: auto; }
|
|
30
|
+
.byline { display: flex; flex-wrap: wrap; gap: 8px 20px; margin-top: 26px; font-size: 13px; color: #77746e; }
|
|
31
|
+
.cover { margin: 0 0 44px; }
|
|
32
|
+
.cover img, .prose img { width: 100%; border-radius: 5px; }
|
|
33
|
+
.prose { font: 19px/1.85 Georgia, 'Times New Roman', serif; overflow-wrap: anywhere; }
|
|
34
|
+
.prose > :first-child { margin-top: 0; }
|
|
35
|
+
.prose p { margin: 1.3em 0; }
|
|
36
|
+
.prose h2 { font: 600 29px/1.3 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; letter-spacing: -.04em; margin: 2em 0 .7em; }
|
|
37
|
+
.prose h3 { font: 600 23px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; letter-spacing: -.03em; margin: 1.7em 0 .6em; }
|
|
38
|
+
.prose h4 { font: 600 19px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 1.6em 0 .5em; }
|
|
39
|
+
.prose h3 a { text-decoration: none; }
|
|
40
|
+
.prose h3 a:hover { text-decoration: underline; }
|
|
41
|
+
.prose ul, .prose ol { padding-left: 1.4em; }
|
|
42
|
+
.prose li { padding-left: .2em; margin: .5em 0; }
|
|
43
|
+
.prose blockquote { border-left: 3px solid #b6bfae; padding: 4px 0 4px 26px; margin: 36px 0; color: #575c50; font-size: 25px; line-height: 1.55; font-style: italic; }
|
|
44
|
+
.prose blockquote p { margin: .3em 0; }
|
|
45
|
+
.prose hr { border: 0; height: 1px; background: #e8e5dd; margin: 42px 0; }
|
|
46
|
+
.prose pre { padding: 24px; overflow: auto; border-radius: 6px; font-size: 14px; line-height: 1.8; }
|
|
47
|
+
.prose code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em; }
|
|
48
|
+
.prose :not(pre) > code { padding: .18em .35em; background: #efede6; border-radius: 3px; }
|
|
49
|
+
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin: 32px 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
50
|
+
.card { display: flex; flex-direction: column; align-items: start; padding: 26px; border-radius: 8px; background: #f3f1eb; text-decoration: none; }
|
|
51
|
+
.card:hover { background: #ebe8df; }
|
|
52
|
+
.card-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: #747168; }
|
|
53
|
+
.prose .card h3 { font-size: 22px; margin: 12px 0 6px; }
|
|
54
|
+
.prose .card p { font-size: 15px; line-height: 1.7; color: #6b6962; margin: 0 0 20px; }
|
|
55
|
+
.card-link { font-size: 13px; font-weight: 600; margin-top: auto; }
|
|
56
|
+
.site-footer { padding: 30px 0; font-size: 12px; color: #7b786f; }
|
|
57
|
+
.site-footer a { text-decoration: none; }
|
|
58
|
+
.skip-link { position: absolute; left: 20px; top: -100px; padding: 10px 15px; background: #20201e; color: white; z-index: 10; }
|
|
59
|
+
.skip-link:focus { top: 15px; }
|
|
60
|
+
@media (max-width: 600px) {
|
|
61
|
+
.site-header, .site-footer, .publication, .publication-home { width: calc(100% - 36px); }
|
|
62
|
+
.site-header { min-height: 74px; }
|
|
63
|
+
.brand { font-size: 18px; }
|
|
64
|
+
nav { gap: 14px; font-size: 13px; }
|
|
65
|
+
.publication { margin-top: 34px; }
|
|
66
|
+
.description { font-size: 17px; }
|
|
67
|
+
.prose { font-size: 18px; }
|
|
68
|
+
.cards { grid-template-columns: 1fr; }
|
|
69
|
+
.site-footer { align-items: start; flex-direction: column; gap: 10px; }
|
|
70
|
+
}
|