@lettercamp/cli 0.1.5 → 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.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
1
  # Lettercamp CLI
2
2
 
3
- The Lettercamp CLI. See **[lettercamp](https://www.npmjs.com/package/lettercamp)** for more documentation.
3
+ The core package for the [Lettercamp CLI](https://www.npmjs.com/package/lettercamp).
4
+
5
+ See the **[Lettercamp CLI documentation](https://www.lettercamp.com/docs/cli)** for installation instructions, commands, options, and troubleshooting.
@@ -0,0 +1,177 @@
1
+ import { chmod, mkdir, readFile, realpath, rename, rm, writeFile, access } from 'node:fs/promises';
2
+ import { homedir, hostname } from 'node:os';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ import path from 'node:path';
6
+ const configRoot = () => process.env.LETTERCAMP_CONFIG_DIR || path.join(process.platform === 'win32' ? process.env.APPDATA || path.join(homedir(), 'AppData', 'Roaming') : process.env.XDG_CONFIG_HOME || path.join(homedir(), '.config'), 'lettercamp');
7
+ export function apiOrigin() {
8
+ const url = new URL(process.env.LETTERCAMP_API_URL || 'https://www.lettercamp.com');
9
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
10
+ if ((url.protocol !== 'https:' && !(local && url.protocol === 'http:')) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
11
+ throw new Error('LETTERCAMP_API_URL must be an HTTPS origin (HTTP is allowed for localhost development).');
12
+ }
13
+ return url.origin;
14
+ }
15
+ async function readConfig(name) {
16
+ try {
17
+ return JSON.parse(await readFile(path.join(configRoot(), name), 'utf8'));
18
+ }
19
+ catch (error) {
20
+ if (error.code === 'ENOENT')
21
+ return null;
22
+ throw new Error(`Unable to read Lettercamp ${name}. Check ${configRoot()}.`);
23
+ }
24
+ }
25
+ async function saveConfig(name, value) {
26
+ await mkdir(configRoot(), { recursive: true, mode: 0o700 });
27
+ if (process.platform !== 'win32')
28
+ await chmod(configRoot(), 0o700);
29
+ const temporary = path.join(configRoot(), `.${name}-${randomUUID()}`);
30
+ try {
31
+ await writeFile(temporary, JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
32
+ await rename(temporary, path.join(configRoot(), name));
33
+ }
34
+ finally {
35
+ await rm(temporary, { force: true });
36
+ }
37
+ }
38
+ export async function credentials() {
39
+ const saved = await readConfig('credentials.json');
40
+ if (!saved || saved.origin !== apiOrigin() || typeof saved.token !== 'string' || !/^lc_[a-f0-9]{64}$/.test(saved.token)) {
41
+ throw new Error('Run lettercamp login to sign in to this server.');
42
+ }
43
+ return saved;
44
+ }
45
+ export async function apiRequest(route, options = {}, token) {
46
+ const headers = new Headers(options.headers);
47
+ if (token)
48
+ headers.set('Authorization', `Bearer ${token}`);
49
+ try {
50
+ return await fetch(new URL(route, apiOrigin()), { ...options, headers, redirect: 'error', signal: AbortSignal.timeout(30_000) });
51
+ }
52
+ catch {
53
+ throw new Error('Unable to reach Lettercamp. Check your connection and try again.');
54
+ }
55
+ }
56
+ export async function responseJson(response) {
57
+ let result;
58
+ try {
59
+ result = await response.json();
60
+ }
61
+ catch {
62
+ throw new Error(`Lettercamp returned an unexpected response (HTTP ${response.status}).`);
63
+ }
64
+ if (!response.ok) {
65
+ if (response.status === 401)
66
+ throw new Error('Your CLI session expired or was revoked. Run lettercamp login again.');
67
+ const problem = result;
68
+ throw new Error(typeof problem?.message === 'string' ? problem.message : typeof problem?.error === 'string' ? problem.error : `Request failed (HTTP ${response.status}).`);
69
+ }
70
+ return result;
71
+ }
72
+ async function openBrowser(url) {
73
+ const [command, args] = process.platform === 'darwin' ? ['open', [url]]
74
+ : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]];
75
+ await new Promise((resolve, reject) => {
76
+ const child = spawn(command, [...args], { stdio: 'ignore', shell: false });
77
+ child.once('error', reject);
78
+ child.once('exit', code => code === 0 ? resolve() : reject(new Error('Browser could not open.')));
79
+ });
80
+ }
81
+ export async function login(options) {
82
+ const previous = await readConfig('credentials.json');
83
+ const name = options.name || `Lettercamp CLI on ${hostname()}`.slice(0, 80);
84
+ const login = await responseJson(await apiRequest('/api/cli/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }));
85
+ const verification = new URL(login.verification_uri_complete);
86
+ if (verification.origin !== apiOrigin() || verification.pathname !== '/cli/login')
87
+ throw new Error('Lettercamp returned an unexpected login address.');
88
+ console.error(`Your code: ${login.user_code}\nOpen ${verification.href}\nConfirm the matching code in your browser. Waiting for approval…`);
89
+ if (options.browser)
90
+ await openBrowser(verification.href).catch(() => console.error('Open the link above in a browser to continue.'));
91
+ const deadline = Date.now() + Math.min(login.expires_in, 600) * 1000;
92
+ let interval = Math.max(5, login.interval) * 1000;
93
+ while (Date.now() < deadline) {
94
+ await new Promise(resolve => setTimeout(resolve, interval));
95
+ if (Date.now() >= deadline)
96
+ break;
97
+ const response = await apiRequest('/api/cli/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_code: login.device_code }) });
98
+ const body = await response.json();
99
+ if (body.error === 'authorization_pending')
100
+ continue;
101
+ if (body.error === 'slow_down') {
102
+ interval = Math.min(interval + 5000, 30000);
103
+ continue;
104
+ }
105
+ if (body.error === 'access_denied')
106
+ throw new Error('Login was denied in the browser.');
107
+ if (body.error === 'expired_token')
108
+ throw new Error('Login expired or was already completed. Run lettercamp login again.');
109
+ if (!response.ok || !body.access_token || !/^lc_[a-f0-9]{64}$/.test(body.access_token) || !body.expires_at || !body.user)
110
+ throw new Error(body.message || 'Unable to finish login. Run lettercamp login again.');
111
+ try {
112
+ await saveConfig('credentials.json', { origin: apiOrigin(), token: body.access_token, expiresAt: body.expires_at, user: body.user });
113
+ }
114
+ catch {
115
+ await apiRequest('/api/cli/session', { method: 'DELETE' }, body.access_token).catch(() => undefined);
116
+ throw new Error('Unable to save the CLI credential. Check your configuration directory permissions and run lettercamp login again.');
117
+ }
118
+ if (previous?.origin === apiOrigin() && /^lc_[a-f0-9]{64}$/.test(previous.token)) {
119
+ try {
120
+ const revoked = await apiRequest('/api/cli/session', { method: 'DELETE' }, previous.token);
121
+ if (!revoked.ok && revoked.status !== 401)
122
+ throw new Error();
123
+ }
124
+ catch {
125
+ console.error('Previous session could not be revoked. You can revoke it from your account page.');
126
+ }
127
+ }
128
+ console.log(`Signed in as ${body.user.email}.`);
129
+ return;
130
+ }
131
+ throw new Error('Login timed out. Run lettercamp login again.');
132
+ }
133
+ export async function whoami(options) {
134
+ const saved = await credentials();
135
+ const user = await responseJson(await apiRequest('/api/cli/session', {}, saved.token));
136
+ console.log(options.json ? JSON.stringify(user) : `${user.name} <${user.email}>`);
137
+ }
138
+ export async function logout() {
139
+ const saved = await readConfig('credentials.json');
140
+ if (!saved) {
141
+ console.log('Already signed out.');
142
+ return;
143
+ }
144
+ if (saved.origin !== apiOrigin())
145
+ throw new Error(`This credential belongs to ${saved.origin}. Set LETTERCAMP_API_URL to that origin to sign out.`);
146
+ const response = await apiRequest('/api/cli/session', { method: 'DELETE' }, saved.token);
147
+ if (!response.ok && response.status !== 401)
148
+ await responseJson(response);
149
+ await rm(path.join(configRoot(), 'credentials.json'), { force: true });
150
+ console.log('Signed out.');
151
+ }
152
+ async function projectPath() {
153
+ const cwd = await realpath(process.cwd());
154
+ for (let directory = cwd;; directory = path.dirname(directory)) {
155
+ try {
156
+ await access(path.join(directory, 'settings.json'));
157
+ return directory;
158
+ }
159
+ catch { /* Walk up to the publication root. */ }
160
+ if (path.dirname(directory) === directory)
161
+ return cwd;
162
+ }
163
+ }
164
+ export async function selectUploadSite(token, requested) {
165
+ const { sites } = await responseJson(await apiRequest('/api/cli/sites', {}, token));
166
+ const project = await projectPath();
167
+ const bindings = await readConfig('sites.json') ?? {};
168
+ const binding = bindings[project];
169
+ const selected = requested || (binding?.origin === apiOrigin() ? binding.siteId : undefined);
170
+ const site = selected ? sites.find(item => item.id === selected || item.slug === selected) : sites.length === 1 ? sites[0] : undefined;
171
+ if (!site) {
172
+ if (!sites.length)
173
+ throw new Error('No accessible sites. Create a site in Lettercamp first.');
174
+ throw new Error(`${selected ? 'That site is unavailable to this account.' : 'Choose a site for this publication.'} Use --site <slug>. Available sites: ${sites.map(item => item.slug).join(', ')}`);
175
+ }
176
+ return { site, remember: () => saveConfig('sites.json', { ...bindings, [project]: { origin: apiOrigin(), siteId: site.id } }) };
177
+ }
package/dist/build.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { build } from 'astro';
3
3
  import mdx from '@astrojs/mdx';
4
+ import { unified } from '@astrojs/markdown-remark';
4
5
  import { prepareWorkspace } from './workspace.js';
6
+ import { readingTime } from './reading-time.js';
5
7
  export async function buildSite(directory, options) {
6
8
  const origin = new URL(options.site);
7
9
  if (!['https:', 'http:'].includes(origin.protocol))
@@ -12,7 +14,7 @@ export async function buildSite(directory, options) {
12
14
  if (outDir === root || root.startsWith(outDir + path.sep) || ['pages', 'public'].some(name => overlaps(outDir, path.join(root, name)))) {
13
15
  throw new Error('Output must not replace the publication or its source files.');
14
16
  }
15
- const { workspace, contentRoot, cleanup } = await prepareWorkspace(root);
17
+ const { workspace, cleanup } = await prepareWorkspace(root);
16
18
  const previousCwd = process.cwd();
17
19
  try {
18
20
  // Astro 7 places temporary prerender modules relative to cwd when outDir
@@ -21,13 +23,10 @@ export async function buildSite(directory, options) {
21
23
  await build({
22
24
  root: workspace, configFile: false, site: origin.origin,
23
25
  cacheDir: path.join(workspace, '.cache'),
24
- publicDir: path.join(contentRoot, 'public'), outDir, output: 'static',
26
+ publicDir: path.join(workspace, 'public'), outDir, output: 'static',
25
27
  integrations: [mdx()],
28
+ markdown: { processor: unified({ remarkPlugins: [readingTime] }) },
26
29
  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
30
  });
32
31
  return outDir;
33
32
  }
package/dist/cli.js CHANGED
@@ -1,10 +1,29 @@
1
1
  import { Command, InvalidArgumentError } from 'commander';
2
2
  import { version, showVersion, update } from './version.js';
3
+ import { preview } from './preview.js';
3
4
  export async function run(argv = process.argv) {
4
5
  const program = new Command()
5
6
  .name('lettercamp')
6
7
  .description('Create and preview your Lettercamp publication.')
7
8
  .version(version, '-v, --version', 'Show the installed CLI version');
9
+ program.command('login')
10
+ .description('Sign in through your browser to upload images')
11
+ .option('--no-browser', 'Print the approval link without opening a browser')
12
+ .option('--name <name>', 'Name for this CLI session')
13
+ .action(async (options) => { const { login } = await import('./account.js'); await login(options); });
14
+ program.command('whoami')
15
+ .description('Show the signed-in account')
16
+ .option('--json', 'Print account details as JSON')
17
+ .action(async (options) => { const { whoami } = await import('./account.js'); await whoami(options); });
18
+ program.command('logout')
19
+ .description('Revoke this CLI session and remove its local credential')
20
+ .action(async () => { const { logout } = await import('./account.js'); await logout(); });
21
+ program.command('upload')
22
+ .description('Upload an image and print its permanent URL')
23
+ .argument('<file>', 'JPEG, PNG, WebP, or GIF image, up to 10 MB')
24
+ .option('--site <slug>', 'Destination site (remembered for this local publication)')
25
+ .option('--json', 'Print the URL and dimensions as JSON')
26
+ .action(async (file, options) => { const { upload } = await import('./upload.js'); await upload(file, options); });
8
27
  program.command('new')
9
28
  .description('Download the starter into a new or empty directory')
10
29
  .argument('[directory]', 'Destination directory', 'my-lettercamp')
@@ -20,7 +39,7 @@ export async function run(argv = process.argv) {
20
39
  .action(update);
21
40
  program.command('dev')
22
41
  .description('Preview the current publication with live reload')
23
- .option('-p, --port <number>', 'Local port', (value) => {
42
+ .option('-p, --port <number>', 'Preferred local port (tries the next available port if busy)', (value) => {
24
43
  if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) {
25
44
  throw new InvalidArgumentError('Use a port between 1 and 65535.');
26
45
  }
@@ -28,9 +47,11 @@ export async function run(argv = process.argv) {
28
47
  }, 3000)
29
48
  .option('--host <address>', 'Host to listen on', 'localhost')
30
49
  .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);
50
+ .action(async (options, command) => {
51
+ await preview(process.cwd(), {
52
+ ...options,
53
+ useDefaultPorts: command.getOptionValueSource('port') === 'default',
54
+ });
34
55
  });
35
56
  program.command('build')
36
57
  .description('Build a static publication for hosting')
package/dist/dev.js CHANGED
@@ -2,8 +2,14 @@ import { rm } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { dev } from 'astro';
4
4
  import mdx from '@astrojs/mdx';
5
+ import { unified } from '@astrojs/markdown-remark';
5
6
  import { prepareWorkspace } from './workspace.js';
7
+ import { selectDefaultPort } from './ports.js';
8
+ import { readingTime } from './reading-time.js';
9
+ import { updatePublicAsset } from './public-assets.js';
6
10
  export async function startDev(directory, options) {
11
+ // Astro is bundled with Lettercamp; users update it through the Lettercamp CLI.
12
+ process.env.ASTRO_DISABLE_UPDATE_CHECK = 'true';
7
13
  const { workspace, contentRoot, modules } = await prepareWorkspace(directory);
8
14
  let server;
9
15
  let closeVite;
@@ -31,39 +37,53 @@ export async function startDev(directory, options) {
31
37
  });
32
38
  };
33
39
  try {
40
+ const port = (options.useDefaultPorts ?? options.port === 3000)
41
+ ? await selectDefaultPort(options.host)
42
+ : options.port;
34
43
  server = await dev({
35
44
  root: workspace,
45
+ logLevel: 'warn',
36
46
  configFile: false,
37
- publicDir: path.join(contentRoot, 'public'),
47
+ publicDir: path.join(workspace, 'public'),
38
48
  output: 'static',
39
49
  integrations: [mdx()],
50
+ markdown: { processor: unified({ remarkPlugins: [readingTime] }) },
40
51
  devToolbar: { enabled: false },
41
- server: { port: options.port, host: options.host, open: options.open },
52
+ server: { port, host: options.host, open: options.open },
42
53
  vite: {
54
+ clearScreen: false,
55
+ logLevel: 'warn',
43
56
  plugins: [{
44
57
  name: 'lettercamp-content',
45
58
  configureServer(vite) {
46
59
  closeVite = () => vite.close();
60
+ // Lettercamp has no Socket.IO endpoint. Stray polling clients should
61
+ // receive a 404 without repeatedly triggering Astro's page warnings.
62
+ vite.middlewares.use((request, response, next) => {
63
+ const pathname = request.url?.split('?', 1)[0];
64
+ if (pathname !== '/socket.io' && pathname !== '/socket.io/')
65
+ return next();
66
+ response.statusCode = 404;
67
+ response.setHeader('Cache-Control', 'no-store');
68
+ response.setHeader('Content-Type', 'text/plain; charset=utf-8');
69
+ response.end('Not found');
70
+ });
47
71
  // Imported files are watched automatically, but external folders
48
72
  // 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')]);
73
+ vite.watcher.add([path.join(contentRoot, 'pages'), path.join(contentRoot, 'public'), path.join(contentRoot, 'settings.json')]);
74
+ vite.watcher.on('all', (event, file) => {
75
+ void updatePublicAsset(contentRoot, workspace, file, event).catch(error => {
76
+ vite.config.logger.error(`Unable to update public file: ${error.message}`);
77
+ });
78
+ });
50
79
  },
51
80
  }],
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] } },
81
+ server: { strictPort: false, fs: { allow: [workspace, contentRoot, modules] } },
61
82
  },
62
83
  });
63
84
  process.once('SIGINT', stop);
64
85
  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 };
86
+ return { stop: cleanup, server, contentRoot };
67
87
  }
68
88
  catch (error) {
69
89
  await cleanup();
@@ -1,8 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import semver from 'semver';
3
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}).`);
4
+ if (!semver.satisfies(process.versions.node, '>=22.17.0')) {
5
+ throw new Error(`Node.js 22.17 or newer is required (you have ${process.versions.node}).`);
6
+ }
7
+ if (!semver.satisfies(process.versions.node, '>=22.22.0')) {
8
+ console.warn(`Lettercamp: Continuing with Node.js ${process.versions.node}. Some dependencies require Node.js 22.22 or newer; upgrade if you encounter compatibility issues.`);
6
9
  }
7
10
  const { run } = await import('./cli.js');
8
11
  await run();
package/dist/ports.js ADDED
@@ -0,0 +1,31 @@
1
+ import { createServer } from 'node:net';
2
+ async function isAvailable(port, host) {
3
+ return new Promise((resolve, reject) => {
4
+ const probe = createServer();
5
+ probe.once('error', (error) => {
6
+ if (error.code === 'EADDRINUSE')
7
+ resolve(false);
8
+ // IPv6 may be disabled on the machine. The IPv4 check still applies.
9
+ else if (['::', '::1'].includes(host) && ['EAFNOSUPPORT', 'EADDRNOTAVAIL'].includes(error.code ?? ''))
10
+ resolve(true);
11
+ else
12
+ reject(error);
13
+ });
14
+ probe.once('listening', () => probe.close(() => resolve(true)));
15
+ probe.listen({ port, host, exclusive: true });
16
+ });
17
+ }
18
+ export async function selectDefaultPort(host = 'localhost') {
19
+ const hosts = new Set([host, '0.0.0.0', '::', '127.0.0.1', '::1']);
20
+ candidates: for (const port of [3000, 8888, 4000, 5000, 6000, 7000, 8000]) {
21
+ // Check both address families, including wildcard listeners that can coexist
22
+ // with localhost on some systems but make the preview URL ambiguous.
23
+ for (const address of hosts) {
24
+ if (!await isAvailable(port, address))
25
+ continue candidates;
26
+ }
27
+ return port;
28
+ }
29
+ // Vite scans sequentially from here and handles bind-time collisions too.
30
+ return 3001;
31
+ }
@@ -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, images, and settings.json reload automatically.');
43
+ console.log('Press Ctrl+C to stop.\n');
44
+ }
45
+ finally {
46
+ finishLoading();
47
+ }
48
+ }
@@ -0,0 +1,29 @@
1
+ import { cp, mkdir, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ // Publication images live at remote URLs. Keep other public files (robots.txt,
4
+ // downloads, etc.) available without copying images into each deployment.
5
+ const imageFile = /\.(?:avif|bmp|gif|heic|heif|ico|jpe?g|png|svg|tiff?|webp)$/i;
6
+ export async function copyPublicAssets(contentRoot, workspace) {
7
+ await mkdir(path.join(workspace, 'public'), { recursive: true });
8
+ try {
9
+ await cp(path.join(contentRoot, 'public'), path.join(workspace, 'public'), {
10
+ recursive: true, filter: source => !imageFile.test(source),
11
+ });
12
+ }
13
+ catch (error) {
14
+ if (error.code !== 'ENOENT')
15
+ throw error;
16
+ }
17
+ }
18
+ export async function updatePublicAsset(contentRoot, workspace, file, event) {
19
+ const relative = path.relative(path.join(contentRoot, 'public'), file);
20
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative) || imageFile.test(relative))
21
+ return;
22
+ const destination = path.join(workspace, 'public', relative);
23
+ if (event === 'unlink' || event === 'unlinkDir')
24
+ await rm(destination, { recursive: true, force: true });
25
+ else if (event === 'add' || event === 'change') {
26
+ await mkdir(path.dirname(destination), { recursive: true });
27
+ await cp(file, destination);
28
+ }
29
+ }
@@ -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,27 @@
1
+ import { open } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { apiRequest, credentials, responseJson, selectUploadSite } from './account.js';
4
+ export async function upload(file, options) {
5
+ const saved = await credentials();
6
+ const handle = await open(file, 'r');
7
+ let bytes;
8
+ try {
9
+ const info = await handle.stat();
10
+ if (!info.isFile() || !info.size)
11
+ throw new Error('Choose a nonempty image file.');
12
+ if (info.size > 10 * 1024 * 1024)
13
+ throw new Error('Images must be 10 MB or smaller.');
14
+ bytes = await handle.readFile();
15
+ if (bytes.byteLength > 10 * 1024 * 1024)
16
+ throw new Error('Images must be 10 MB or smaller.');
17
+ }
18
+ finally {
19
+ await handle.close();
20
+ }
21
+ const { site, remember } = await selectUploadSite(saved.token, options.site);
22
+ const result = await responseJson(await apiRequest(`/api/sites/${encodeURIComponent(site.slug)}/assets`, {
23
+ method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-File-Name': encodeURIComponent(path.basename(file)) }, body: new Uint8Array(bytes),
24
+ }, saved.token));
25
+ await remember().catch(() => console.error('Uploaded successfully, but the site selection could not be saved. Use --site on your next upload.'));
26
+ console.log(options.json ? JSON.stringify(result) : result.url);
27
+ }
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 { copyPublicAssets } from './public-assets.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, 'site.json'), 'utf8'));
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 copyPublicAssets(contentRoot, workspace);
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,9 @@ 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
- `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
+ `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'), `export { default } from ${JSON.stringify(path.join(contentRoot, 'settings.json').replaceAll('\\', '/'))};\n`);
26
29
  return { workspace, contentRoot, modules, cleanup: () => rm(workspace, { recursive: true, force: true, maxRetries: 3 }) };
27
30
  }
28
31
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lettercamp/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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.19.0"
29
+ "node": ">=22.17.0"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc",
@@ -38,6 +38,7 @@
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",
42
43
  "astro": "7.3.8",
43
44
  "commander": "^15.0.0",
@@ -0,0 +1,17 @@
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;
7
+ ---
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>
13
+ </span>}
14
+ {author && showDate && <span class="byline-separator" aria-hidden="true">·</span>}
15
+ {showDate && <time datetime={post.date.iso}>{post.date.label}</time>}
16
+ {showReadingTime && <span class="reading-time">{post.readingMinutes} min read</span>}
17
+ </div>}