@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.
Files changed (61) hide show
  1. package/README.md +3 -1
  2. package/dist/account.js +189 -0
  3. package/dist/build.js +24 -9
  4. package/dist/cli.js +35 -5
  5. package/dist/code-block.js +50 -0
  6. package/dist/code-detection.js +24 -0
  7. package/dist/code-highlight.js +54 -0
  8. package/dist/custom-layout.js +32 -0
  9. package/dist/deploy.js +131 -0
  10. package/dist/dev.js +52 -15
  11. package/dist/domain.js +190 -0
  12. package/dist/hosting.js +132 -0
  13. package/dist/lettercamp.js +2 -2
  14. package/dist/media-dimensions.js +154 -0
  15. package/dist/new.js +1 -1
  16. package/dist/ports.js +31 -0
  17. package/dist/preview.js +48 -0
  18. package/dist/prompts.js +31 -0
  19. package/dist/publication-links.js +23 -0
  20. package/dist/react.js +15 -0
  21. package/dist/reading-time.js +25 -0
  22. package/dist/upload.js +57 -0
  23. package/dist/workspace.js +8 -3
  24. package/package.json +12 -2
  25. package/renderer/src/components/AuthorByline.astro +26 -0
  26. package/renderer/src/components/BlogHome.astro +37 -0
  27. package/renderer/src/components/Footer.astro +35 -0
  28. package/renderer/src/components/Header.astro +38 -0
  29. package/renderer/src/components/MobileMenuIcons.astro +7 -0
  30. package/renderer/src/components/PostCard.astro +25 -0
  31. package/renderer/src/components/SocialLink.astro +23 -0
  32. package/renderer/src/layouts/SiteLayout.astro +73 -27
  33. package/renderer/src/lib/analytics.ts +21 -0
  34. package/renderer/src/lib/author-dialog.ts +102 -0
  35. package/renderer/src/lib/authors.js +18 -0
  36. package/renderer/src/lib/block-widths.ts +30 -0
  37. package/renderer/src/lib/branding.js +15 -0
  38. package/renderer/src/lib/button.js +10 -0
  39. package/renderer/src/lib/category-routes.js +19 -0
  40. package/renderer/src/lib/code-block.ts +46 -0
  41. package/renderer/src/lib/fonts.js +33 -0
  42. package/renderer/src/lib/home-layout.ts +5 -0
  43. package/renderer/src/lib/image-radius.ts +5 -0
  44. package/renderer/src/lib/image-rounding.ts +28 -0
  45. package/renderer/src/lib/image-widths.ts +13 -0
  46. package/renderer/src/lib/metadata.js +47 -0
  47. package/renderer/src/lib/mobile-navigation.ts +76 -0
  48. package/renderer/src/lib/post-layout.ts +6 -0
  49. package/renderer/src/lib/post-share.ts +264 -0
  50. package/renderer/src/lib/post-toc.ts +69 -0
  51. package/renderer/src/lib/posts.js +40 -0
  52. package/renderer/src/lib/publication-url.js +14 -0
  53. package/renderer/src/lib/table-styles.ts +12 -0
  54. package/renderer/src/lib/urls.js +3 -0
  55. package/renderer/src/pages/[...slug].astro +50 -25
  56. package/renderer/src/pages/category/[tag].astro +16 -0
  57. package/renderer/src/pages/category/index.astro +4 -0
  58. package/renderer/src/pages/index.astro +10 -0
  59. package/renderer/src/styles/post-share.css +57 -0
  60. package/renderer/src/styles/site.css +352 -51
  61. package/renderer/src/layouts/Content.astro +0 -1
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,189 @@
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
+ export 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
+ export 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, timeout = 30_000) {
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(timeout) });
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, publishing: true }) }));
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
+ export 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. Run lettercamp deploy to create one.');
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: () => saveBinding({ ...(binding?.origin === apiOrigin() && binding.siteId === site.id ? binding : {}), origin: apiOrigin(), siteId: site.id }) };
177
+ }
178
+ export async function getBinding() {
179
+ const bindings = await readConfig('sites.json') ?? {};
180
+ const binding = bindings[await projectPath()];
181
+ return binding?.origin === apiOrigin() ? binding : null;
182
+ }
183
+ export async function saveBinding(binding) {
184
+ const bindings = await readConfig('sites.json') ?? {};
185
+ await saveConfig('sites.json', { ...bindings, [await projectPath()]: binding });
186
+ }
187
+ export async function removeConfig(name) {
188
+ await rm(path.join(configRoot(), name), { force: true });
189
+ }
package/dist/build.js CHANGED
@@ -1,38 +1,53 @@
1
+ import { publicationLinks } from './publication-links.js';
2
+ import { codeBlocks } from "./code-highlight.js";
1
3
  import path from 'node:path';
2
4
  import { build } from 'astro';
3
5
  import mdx from '@astrojs/mdx';
6
+ import { unified } from '@astrojs/markdown-remark';
4
7
  import { prepareWorkspace } from './workspace.js';
8
+ import { readingTime } from './reading-time.js';
9
+ import { mediaDimensions } from './media-dimensions.js';
10
+ import { reactViteConfig } from './react.js';
5
11
  export async function buildSite(directory, options) {
6
12
  const origin = new URL(options.site);
7
13
  if (!['https:', 'http:'].includes(origin.protocol))
8
14
  throw new Error('Use an HTTP or HTTPS site URL.');
15
+ if (origin.username || origin.password || origin.search || origin.hash)
16
+ throw new Error('Use a site URL without credentials, query parameters, or a fragment.');
9
17
  const root = path.resolve(directory);
10
18
  const outDir = path.resolve(root, options.outDir ?? 'dist');
11
19
  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)))) {
20
+ if (outDir === root || root.startsWith(outDir + path.sep) || ['pages', 'public', 'components'].some(name => overlaps(outDir, path.join(root, name)))) {
13
21
  throw new Error('Output must not replace the publication or its source files.');
14
22
  }
15
- const { workspace, contentRoot, cleanup } = await prepareWorkspace(root);
23
+ const { workspace, cleanup } = await prepareWorkspace(root);
16
24
  const previousCwd = process.cwd();
25
+ const previousNodeEnv = process.env.NODE_ENV;
17
26
  try {
27
+ // The React integration imports react-dom to detect its version. Select the
28
+ // production runtime before loading it, as Astro's CLI does before a build.
29
+ process.env.NODE_ENV = 'production';
30
+ const { default: react } = await import('@astrojs/react');
18
31
  // Astro 7 places temporary prerender modules relative to cwd when outDir
19
32
  // is external. Keep them beside the renderer's node_modules, not the content.
20
33
  process.chdir(workspace);
21
34
  await build({
22
- root: workspace, configFile: false, site: origin.origin,
35
+ root: workspace, configFile: false, site: origin.origin, base: origin.pathname, trailingSlash: 'always',
23
36
  cacheDir: path.join(workspace, '.cache'),
24
- publicDir: path.join(contentRoot, 'public'), outDir, output: 'static',
25
- integrations: [mdx()],
37
+ publicDir: path.join(workspace, '.lettercamp-no-public'), outDir, output: 'static',
38
+ integrations: [mdx(), react()],
39
+ vite: reactViteConfig,
40
+ markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [readingTime, codeBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
26
41
  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
42
  });
32
43
  return outDir;
33
44
  }
34
45
  finally {
35
46
  process.chdir(previousCwd);
47
+ if (previousNodeEnv === undefined)
48
+ delete process.env.NODE_ENV;
49
+ else
50
+ process.env.NODE_ENV = previousNodeEnv;
36
51
  await cleanup();
37
52
  }
38
53
  }
package/dist/cli.js CHANGED
@@ -1,10 +1,30 @@
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
- .description('Create and preview your Lettercamp publication.')
7
+ .description('Create, preview, and publish 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 publish and manage your site')
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 images or fonts and print their permanent URLs')
23
+ .argument('<files...>', 'JPEG, PNG, WebP, GIF, TTF, OTF, WOFF, or WOFF2 files, up to 10 MB each')
24
+ .option('--site <slug>', 'Destination site (remembered for this local publication)')
25
+ .option('--json', 'Print URL and image dimensions or font settings; multiple files return an array')
26
+ .addHelpText('after', '\nExamples:\n lettercamp upload cover.png portrait.jpg --site my-blog\n lettercamp upload brand.woff2 --json\n\nMultiple files print filename<TAB>URL. Failed files are reported and remaining uploads continue.\nThe command exits unsuccessfully if any upload fails. Single-file output is unchanged.')
27
+ .action(async (files, options) => { const { upload } = await import('./upload.js'); await upload(files, options); });
8
28
  program.command('new')
9
29
  .description('Download the starter into a new or empty directory')
10
30
  .argument('[directory]', 'Destination directory', 'my-lettercamp')
@@ -12,6 +32,14 @@ export async function run(argv = process.argv) {
12
32
  const { createSite } = await import('./new.js');
13
33
  await createSite(directory);
14
34
  });
35
+ program.command('deploy')
36
+ .description('Publish this folder; create or connect a site on first use')
37
+ .action(async () => { const { deploy } = await import('./deploy.js'); await deploy(); });
38
+ program.command('domain')
39
+ .description('Connect an address, resume setup, or remove the current connection')
40
+ .argument('[address]', 'A hostname, a URL with a subfolder, or remove')
41
+ .addHelpText('after', '\nExamples:\n lettercamp domain blog.example.com\n lettercamp domain example.com/blog\n lettercamp domain\n lettercamp domain remove')
42
+ .action(async (address) => { const { domain } = await import('./domain.js'); await domain(address); });
15
43
  program.command('version')
16
44
  .description('Show the installed and latest published CLI versions')
17
45
  .action(showVersion);
@@ -20,7 +48,7 @@ export async function run(argv = process.argv) {
20
48
  .action(update);
21
49
  program.command('dev')
22
50
  .description('Preview the current publication with live reload')
23
- .option('-p, --port <number>', 'Local port', (value) => {
51
+ .option('-p, --port <number>', 'Preferred local port (tries the next available port if busy)', (value) => {
24
52
  if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) {
25
53
  throw new InvalidArgumentError('Use a port between 1 and 65535.');
26
54
  }
@@ -28,9 +56,11 @@ export async function run(argv = process.argv) {
28
56
  }, 3000)
29
57
  .option('--host <address>', 'Host to listen on', 'localhost')
30
58
  .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);
59
+ .action(async (options, command) => {
60
+ await preview(process.cwd(), {
61
+ ...options,
62
+ useDefaultPorts: command.getOptionValueSource('port') === 'default',
63
+ });
34
64
  });
35
65
  program.command('build')
36
66
  .description('Build a static publication for hosting')
@@ -0,0 +1,50 @@
1
+ // Generated from lettercamp/lib/code-block.ts. Run pnpm sync:renderer in the app repository.
2
+ export const codeThemes = { classic: "Classic", ocean: "Ocean", forest: "Forest", mono: "Monochrome" };
3
+ const settingPattern = /(?:^|\s)lc-(width|wrap|theme|caption)=([^\s]*)/g;
4
+ export function readCodeSettings(meta = "") {
5
+ const settings = { width: "regular", wrap: true, theme: "classic", caption: "" };
6
+ for (const match of meta.matchAll(settingPattern)) {
7
+ const [, key, value] = match;
8
+ if (key === "width" && (value === "regular" || value === "wide" || value === "full"))
9
+ settings.width = value;
10
+ if (key === "theme" && Object.hasOwn(codeThemes, value))
11
+ settings.theme = value;
12
+ if (key === "wrap")
13
+ settings.wrap = value !== "false";
14
+ if (key === "caption") {
15
+ try {
16
+ settings.caption = decodeURIComponent(value);
17
+ }
18
+ catch { /* Keep malformed source editable. */ }
19
+ }
20
+ }
21
+ return settings;
22
+ }
23
+ export function writeCodeSettings(meta, patch) {
24
+ const settings = { ...readCodeSettings(meta), ...patch };
25
+ return [meta.replace(settingPattern, "").trim(), `lc-width=${settings.width}`, `lc-wrap=${settings.wrap}`, `lc-theme=${settings.theme}`,
26
+ ...(settings.caption ? [`lc-caption=${encodeURIComponent(settings.caption)}`] : [])].filter(Boolean).join(" ");
27
+ }
28
+ // The editor and both publication renderers use the same token classes and palette.
29
+ export const codeBlockStyles = `
30
+ [data-lettercamp-code] { --code-keyword: #8250a0; --code-string: #3d7757; --code-number: #a15c32; --code-name: #326b99; --code-comment: #93939b; color: #414146; }
31
+ [data-lettercamp-code][data-code-width] { margin-block: 24px; }
32
+ [data-lettercamp-code][data-code-theme="ocean"] { --code-keyword: #3d62a8; --code-string: #277c85; --code-number: #98623e; --code-name: #6961a4; }
33
+ [data-lettercamp-code][data-code-theme="forest"] { --code-keyword: #47724a; --code-string: #827030; --code-number: #a25d47; --code-name: #3c7b79; }
34
+ [data-lettercamp-code][data-code-theme="mono"] { --code-keyword: #45454a; --code-string: #62626a; --code-number: #62626a; --code-name: #45454a; }
35
+ [data-lettercamp-code] .tok-keyword, [data-lettercamp-code] .tok-operator { color: var(--code-keyword); }
36
+ [data-lettercamp-code] .tok-string, [data-lettercamp-code] .tok-string2, [data-lettercamp-code] .tok-inserted { color: var(--code-string); }
37
+ [data-lettercamp-code] .tok-number, [data-lettercamp-code] .tok-bool, [data-lettercamp-code] .tok-atom, [data-lettercamp-code] .tok-literal { color: var(--code-number); }
38
+ [data-lettercamp-code] .tok-typeName, [data-lettercamp-code] .tok-className, [data-lettercamp-code] .tok-propertyName, [data-lettercamp-code] .tok-definition, [data-lettercamp-code] .tok-variableName2, [data-lettercamp-code] .tok-labelName { color: var(--code-name); }
39
+ [data-lettercamp-code] .tok-comment, [data-lettercamp-code] .tok-meta { color: var(--code-comment); }
40
+ [data-lettercamp-code] .tok-invalid, [data-lettercamp-code] .tok-deleted { color: #aa4747; }
41
+ [data-lettercamp-code] .tok-strong { font-weight: 600; }
42
+ [data-lettercamp-code] .tok-emphasis { font-style: italic; }
43
+ figure[data-lettercamp-code] > pre { box-sizing: border-box; margin: 0; padding: 16px 0; overflow-x: auto; border-radius: 8px; background: #fafafa; color: #414146; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; tab-size: 4; }
44
+ figure[data-lettercamp-code] > pre > code { display: block; width: max-content; min-width: 100%; padding: 0; background: transparent; color: inherit; font: inherit; white-space: normal; counter-reset: code-line; }
45
+ [data-lettercamp-code] .code-line { position: relative; display: block; box-sizing: border-box; min-height: 1.7em; padding: 0 18px 0 calc(var(--code-gutter, 3ch) + 24px); white-space: pre; }
46
+ [data-lettercamp-code] .code-line::before { position: absolute; left: 8px; width: var(--code-gutter, 3ch); text-align: right; content: counter(code-line); counter-increment: code-line; color: #b4b4bc; user-select: none; font-variant-numeric: tabular-nums; }
47
+ [data-lettercamp-code][data-code-wrap="true"] > pre > code { width: 100%; }
48
+ [data-lettercamp-code][data-code-wrap="true"] .code-line { white-space: pre-wrap; overflow-wrap: anywhere; }
49
+ figure[data-lettercamp-code] > figcaption { margin: 8px 0 0; color: #888; font: 13px/1.5 var(--font-family, inherit); text-align: center; overflow-wrap: anywhere; }
50
+ `;
@@ -0,0 +1,24 @@
1
+ // Generated from lettercamp/lib/code-detection.ts. Run pnpm sync:renderer in the app repository.
2
+ // Keep detection identical in the editor, draft preview, and publication build.
3
+ // Explicit languages (including plain text) always take precedence.
4
+ export const isAutomaticCodeLanguage = (language) => !language.trim() || language.toLowerCase() === "auto";
5
+ const candidates = ["javascript", "typescript", "python", "json", "xml", "css", "bash", "sql", "yaml", "markdown", "java", "c", "cpp", "csharp", "go", "rust", "ruby", "php", "swift", "kotlin"];
6
+ export async function resolveCodeLanguage(code, language = "") {
7
+ if (!isAutomaticCodeLanguage(language))
8
+ return language;
9
+ const sample = code.slice(0, 20000).trim();
10
+ // Prose can score against language keywords; require some code structure too.
11
+ if (!sample || !/[{}()[\];=<>`#$:|\\]/.test(sample))
12
+ return "txt";
13
+ try {
14
+ const { default: detector } = await import("highlight.js/lib/common");
15
+ const result = detector.highlightAuto(sample, candidates.filter(name => name !== "css" || /[{}]|^\s*@[\w-]+/m.test(sample)));
16
+ // Short or ambiguous snippets can remain plain until there is enough evidence.
17
+ if (!result.language || result.relevance < 2)
18
+ return "txt";
19
+ return result.language === "xml" ? "html" : result.language;
20
+ }
21
+ catch {
22
+ return "txt";
23
+ }
24
+ }
@@ -0,0 +1,54 @@
1
+ // Generated from lettercamp/lib/code-highlight.ts. Run pnpm sync:renderer in the app repository.
2
+ import { resolveCodeLanguage } from "./code-detection.js";
3
+ import { languages } from "@codemirror/language-data";
4
+ import { classHighlighter, highlightTree } from "@lezer/highlight";
5
+ import { readCodeSettings } from "./code-block.js";
6
+ const element = (tagName, properties, children) => ({ type: "element", tagName, properties, children });
7
+ export function findCodeLanguage(language) {
8
+ const name = language.toLowerCase();
9
+ if (!name || ["text", "txt", "plaintext"].includes(name))
10
+ return undefined;
11
+ return languages.find(item => item.alias.includes(name) || item.name.toLowerCase() === name || item.name.toLowerCase().replace(/\s+/g, "-") === name || item.extensions.includes(name));
12
+ }
13
+ export async function renderCodeBlock(code, language = "", meta = "") {
14
+ const settings = readCodeSettings(meta);
15
+ const lines = [element("span", { className: ["code-line"] }, [])];
16
+ const append = (value, classes) => {
17
+ value.split("\n").forEach((part, index) => {
18
+ if (index)
19
+ lines.push(element("span", { className: ["code-line"] }, []));
20
+ if (part)
21
+ lines.at(-1).children.push(classes ? element("span", { className: classes.split(" ") }, [{ type: "text", value: part }]) : { type: "text", value: part });
22
+ });
23
+ };
24
+ let offset = 0;
25
+ try {
26
+ const support = await findCodeLanguage(await resolveCodeLanguage(code, language))?.load();
27
+ if (support)
28
+ highlightTree(support.language.parser.parse(code), classHighlighter, (from, to, classes) => {
29
+ append(code.slice(offset, from));
30
+ append(code.slice(from, to), classes);
31
+ offset = to;
32
+ });
33
+ }
34
+ catch { /* Unknown or unavailable grammars still render as readable code. */ }
35
+ append(code.slice(offset));
36
+ const children = [element("pre", { tabIndex: 0 }, [element("code", {}, lines.flatMap((line, index) => index ? [{ type: "text", value: "\n" }, line] : [line]))])];
37
+ if (settings.caption)
38
+ children.push(element("figcaption", {}, [{ type: "text", value: settings.caption }]));
39
+ return element("figure", { dataLettercampCode: "", dataCodeWidth: settings.width, dataCodeWrap: String(settings.wrap), dataCodeTheme: settings.theme }, children);
40
+ }
41
+ // Render a custom block node so Markdown and MDX do not add a second pre wrapper.
42
+ export function codeBlocks() {
43
+ return async (tree) => {
44
+ const visit = async (node) => {
45
+ if (node.type === "code") {
46
+ const block = await renderCodeBlock(node.value ?? "", node.lang ?? "", node.meta ?? "");
47
+ node.type = "lettercampCode";
48
+ node.data = { ...node.data, hName: block.tagName, hProperties: block.properties, hChildren: block.children };
49
+ }
50
+ await Promise.all((node.children ?? []).map(visit));
51
+ };
52
+ await visit(tree);
53
+ };
54
+ }
@@ -0,0 +1,32 @@
1
+ import { readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ // Generate direct Astro imports so React hydration and CSS imports use the same
4
+ // Vite pipeline as article components. Only the two reserved filenames opt in.
5
+ export async function writeCustomLayout(workspace, contentRoot) {
6
+ const directory = path.join(contentRoot, 'components');
7
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
8
+ if (error.code === 'ENOENT')
9
+ return [];
10
+ throw error;
11
+ });
12
+ const sources = ['Header', 'Footer'].map(name => {
13
+ const matches = entries.filter(entry => (entry.isFile() || entry.isSymbolicLink()) && [name + '.jsx', name + '.tsx'].includes(entry.name));
14
+ if (matches.length > 1)
15
+ throw new Error(`Use only one of components/${name}.jsx and components/${name}.tsx.`);
16
+ const filename = matches[0]?.name;
17
+ const region = name.toLowerCase();
18
+ const source = filename
19
+ ? `---\nimport Component from ${JSON.stringify(path.join(directory, filename).replaceAll('\\', '/'))};\nconst { site, pathname } = Astro.props;\nconst basePath = import.meta.env.BASE_URL.replace(/\\/+$/, '');\n---\n<lettercamp-custom-${region} data-component="${filename}" style="display: contents"><Component site={site} pathname={pathname} basePath={basePath} client:load /></lettercamp-custom-${region}>\n`
20
+ : `---\nimport Component from './${name}.astro';\n---\n<Component {...Astro.props} />\n`;
21
+ return { file: path.join(workspace, 'src', 'components', `${name}Slot.astro`), source };
22
+ });
23
+ for (const { file, source } of sources) {
24
+ const previous = await readFile(file, 'utf8').catch((error) => {
25
+ if (error.code === 'ENOENT')
26
+ return '';
27
+ throw error;
28
+ });
29
+ if (previous !== source)
30
+ await writeFile(file, source);
31
+ }
32
+ }
package/dist/deploy.js ADDED
@@ -0,0 +1,131 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { randomUUID } from 'node:crypto';
5
+ import path from 'node:path';
6
+ import { projectPath, getBinding, saveBinding, readConfig, saveConfig, removeConfig } from './account.js';
7
+ import { ApiError, publishingToken, request, selectHostingSite, status, waitForDeployment } from './hosting.js';
8
+ import { confirm } from './prompts.js';
9
+ const roots = ['pages', 'components', 'lib', 'styles', 'assets'];
10
+ export async function publicationFiles(root) {
11
+ const files = [];
12
+ let total = 0;
13
+ async function visit(relative) {
14
+ const filename = path.join(root, relative);
15
+ const info = await lstat(filename);
16
+ if (info.isSymbolicLink())
17
+ throw new Error(`Source uploads do not follow symlinks: ${relative}`);
18
+ if (info.isDirectory()) {
19
+ for (const entry of (await readdir(filename)).sort()) {
20
+ if (entry.startsWith('.') || ['node_modules', 'dist', 'build', 'coverage'].includes(entry) || /\.(pem|key|p12|pfx)$/i.test(entry))
21
+ continue;
22
+ await visit(`${relative}/${entry}`);
23
+ }
24
+ return;
25
+ }
26
+ if (!info.isFile())
27
+ throw new Error(`Source uploads require regular files: ${relative}`);
28
+ if (info.size > 5 * 1024 * 1024)
29
+ throw new Error(`${relative} exceeds 5 MB. Use lettercamp upload for large assets.`);
30
+ const handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
31
+ let bytes;
32
+ try {
33
+ bytes = await handle.readFile();
34
+ }
35
+ finally {
36
+ await handle.close();
37
+ }
38
+ total += bytes.length;
39
+ if (bytes.length > 5 * 1024 * 1024 || total > 10 * 1024 * 1024 || files.length >= 2000)
40
+ throw new Error('Source uploads support 2,000 files, 5 MB per file, and 10 MB total. Use lettercamp upload for large assets.');
41
+ files.push({ path: relative, content: bytes.toString('base64') });
42
+ }
43
+ await visit('settings.json');
44
+ for (const directory of roots) {
45
+ try {
46
+ await lstat(path.join(root, directory));
47
+ }
48
+ catch (error) {
49
+ if (error.code === 'ENOENT' && directory !== 'pages')
50
+ continue;
51
+ throw error;
52
+ }
53
+ await visit(directory);
54
+ }
55
+ if (!files.some(file => /^pages\/.+\.mdx?$/.test(file.path)))
56
+ throw new Error('Include at least one Markdown or MDX file in pages/.');
57
+ JSON.parse(Buffer.from(files.find(file => file.path === 'settings.json').content, 'base64').toString('utf8'));
58
+ return files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
59
+ }
60
+ export async function deploy() {
61
+ const root = await projectPath();
62
+ // Catch the wrong directory before opening a browser or creating a hosted site.
63
+ await readFile(path.join(root, 'settings.json'), 'utf8').catch(() => { throw new Error('Run lettercamp deploy inside your publication folder containing settings.json.'); });
64
+ const files = (await getBinding())?.pending ? null : await publicationFiles(root);
65
+ const token = await publishingToken();
66
+ let { site, binding } = await selectHostingSite(token, true);
67
+ let current = await status(token, site.id, binding.pending);
68
+ if (!current.managed)
69
+ throw new Error('This site uses a connected GitHub repository. Push your changes to that repository to publish.');
70
+ let snapshot;
71
+ if (binding.pending) {
72
+ const pending = await readConfig(`deployment-${binding.pending}.json`);
73
+ if (!pending)
74
+ throw new Error('The saved upload is missing. Restore your Lettercamp configuration before resuming this deployment.');
75
+ snapshot = pending;
76
+ console.error('Resuming the saved deployment. New local edits can be published with the next lettercamp deploy.');
77
+ }
78
+ else {
79
+ if (current.pending) {
80
+ console.error('Waiting for the current publication to finish…');
81
+ await waitForDeployment(token, site.id, current.pending);
82
+ current = await status(token, site.id);
83
+ }
84
+ if (!current.revision) {
85
+ console.error('Finishing site provisioning…');
86
+ await request(token, `sites/${site.id}/status`, { action: 'retry' });
87
+ current = await status(token, site.id);
88
+ if (current.pending)
89
+ await waitForDeployment(token, site.id, current.pending);
90
+ current = await status(token, site.id);
91
+ if (!current.revision)
92
+ throw new Error('The site repository is not ready. Run lettercamp deploy again shortly.');
93
+ }
94
+ if (binding.revision !== current.revision && !binding.fresh) {
95
+ console.error(binding.revision ? 'The hosted source changed since your last deployment.' : `This folder will replace the publication source on ${site.slug}.lettercamp.site.`);
96
+ console.error('Local settings, pages, components, lib, styles, and assets replace those remote files, including deletions.');
97
+ if (!await confirm('Replace the hosted publication with this folder?'))
98
+ return;
99
+ }
100
+ snapshot = { id: randomUUID(), revision: current.revision, files: files ?? await publicationFiles(root) };
101
+ await saveConfig(`deployment-${snapshot.id}.json`, snapshot);
102
+ binding = { ...binding, pending: snapshot.id, fresh: undefined };
103
+ await saveBinding(binding);
104
+ console.error(`Deploying ${snapshot.files.length} source files to ${site.slug}.lettercamp.site…`);
105
+ }
106
+ if (!current.activity || current.activity.id !== snapshot.id) {
107
+ try {
108
+ await request(token, `sites/${site.id}/deploy`, snapshot);
109
+ }
110
+ catch (error) {
111
+ // Validation/conflict responses are definitive; network failures retain the upload.
112
+ if (error instanceof ApiError && [400, 403, 409, 413].includes(error.status)) {
113
+ binding = { ...binding, pending: undefined };
114
+ await saveBinding(binding);
115
+ await removeConfig(`deployment-${snapshot.id}.json`);
116
+ }
117
+ throw error;
118
+ }
119
+ }
120
+ const result = await waitForDeployment(token, site.id, snapshot.id, async (activity) => {
121
+ const revision = activity.sourceCommit && activity.baseRevision ? `${activity.baseRevision.split(':')[0]}:${activity.sourceCommit}` : binding.revision;
122
+ const finished = ['succeeded', 'failed', 'cancelled'].includes(activity.status);
123
+ if (revision !== binding.revision || finished) {
124
+ binding = { ...binding, revision, pending: finished ? undefined : snapshot.id };
125
+ await saveBinding(binding);
126
+ if (finished)
127
+ await removeConfig(`deployment-${snapshot.id}.json`);
128
+ }
129
+ });
130
+ console.log(`Live at ${result.site.url}`);
131
+ }