@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/dist/dev.js CHANGED
@@ -1,9 +1,19 @@
1
+ import { codeBlocks } from "./code-highlight.js";
1
2
  import { rm } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { dev } from 'astro';
4
5
  import mdx from '@astrojs/mdx';
6
+ import react from '@astrojs/react';
7
+ import { unified } from '@astrojs/markdown-remark';
5
8
  import { prepareWorkspace } from './workspace.js';
9
+ import { selectDefaultPort } from './ports.js';
10
+ import { readingTime } from './reading-time.js';
11
+ import { mediaDimensions } from './media-dimensions.js';
12
+ import { reactIntegrationDirectory, reactViteConfig } from './react.js';
13
+ import { writeCustomLayout } from './custom-layout.js';
6
14
  export async function startDev(directory, options) {
15
+ // Astro is bundled with Lettercamp; users update it through the Lettercamp CLI.
16
+ process.env.ASTRO_DISABLE_UPDATE_CHECK = 'true';
7
17
  const { workspace, contentRoot, modules } = await prepareWorkspace(directory);
8
18
  let server;
9
19
  let closeVite;
@@ -31,39 +41,66 @@ export async function startDev(directory, options) {
31
41
  });
32
42
  };
33
43
  try {
44
+ const port = (options.useDefaultPorts ?? options.port === 3000)
45
+ ? await selectDefaultPort(options.host)
46
+ : options.port;
34
47
  server = await dev({
35
48
  root: workspace,
49
+ logLevel: 'warn',
36
50
  configFile: false,
37
- publicDir: path.join(contentRoot, 'public'),
51
+ publicDir: path.join(workspace, '.lettercamp-no-public'),
38
52
  output: 'static',
39
- integrations: [mdx()],
53
+ integrations: [mdx(), react()],
54
+ markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [readingTime, codeBlocks, [mediaDimensions, { root: contentRoot }]], rehypePlugins: [[mediaDimensions, { root: contentRoot }]] }) },
40
55
  devToolbar: { enabled: false },
41
- server: { port: options.port, host: options.host, open: options.open },
56
+ server: { port, host: options.host, open: options.open },
42
57
  vite: {
58
+ ...reactViteConfig,
59
+ clearScreen: false,
60
+ logLevel: 'warn',
43
61
  plugins: [{
44
62
  name: 'lettercamp-content',
45
63
  configureServer(vite) {
46
64
  closeVite = () => vite.close();
65
+ // Lettercamp has no Socket.IO endpoint. Stray polling clients should
66
+ // receive a 404 without repeatedly triggering Astro's page warnings.
67
+ vite.middlewares.use((request, response, next) => {
68
+ const pathname = request.url?.split('?', 1)[0];
69
+ if (pathname !== '/socket.io' && pathname !== '/socket.io/')
70
+ return next();
71
+ response.statusCode = 404;
72
+ response.setHeader('Cache-Control', 'no-store');
73
+ response.setHeader('Content-Type', 'text/plain; charset=utf-8');
74
+ response.end('Not found');
75
+ });
47
76
  // Imported files are watched automatically, but external folders
48
77
  // 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')]);
78
+ const components = path.join(contentRoot, 'components');
79
+ vite.watcher.add([path.join(contentRoot, 'pages'), path.join(contentRoot, 'settings.json'), components]);
80
+ let updatingLayout = Promise.resolve();
81
+ const updateLayout = (file) => {
82
+ if (path.dirname(file) !== components || !/^(Header|Footer)\.(jsx|tsx)$/.test(path.basename(file)))
83
+ return;
84
+ updatingLayout = updatingLayout.then(async () => {
85
+ await writeCustomLayout(workspace, contentRoot);
86
+ // Also clear an earlier duplicate-file error when the valid
87
+ // slot source did not change. Add/remove is a rare full reload.
88
+ vite.moduleGraph.invalidateAll();
89
+ vite.ws.send({ type: 'full-reload' });
90
+ }).catch(error => {
91
+ vite.config.logger.error(error.message);
92
+ vite.ws.send({ type: 'error', err: { message: error.message, stack: '' } });
93
+ });
94
+ };
95
+ vite.watcher.on('add', updateLayout).on('unlink', updateLayout);
50
96
  },
51
97
  }],
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] } },
98
+ server: { strictPort: false, fs: { allow: [workspace, contentRoot, modules, reactIntegrationDirectory] } },
61
99
  },
62
100
  });
63
101
  process.once('SIGINT', stop);
64
102
  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 };
103
+ return { stop: cleanup, server, contentRoot };
67
104
  }
68
105
  catch (error) {
69
106
  await cleanup();
package/dist/domain.js ADDED
@@ -0,0 +1,190 @@
1
+ import { saveBinding } from './account.js';
2
+ import { choose, ask, confirm } from './prompts.js';
3
+ import { delay, publishingToken, request, selectHostingSite, status, waitForDeployment } from './hosting.js';
4
+ export function domainAddress(address) {
5
+ let url;
6
+ try {
7
+ url = new URL(address.includes('://') ? address : `https://${address}`);
8
+ }
9
+ catch {
10
+ throw new Error('Enter an address such as blog.example.com or example.com/blog.');
11
+ }
12
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash
13
+ || /[?#@\\\s]/.test(address) || /:\d/.test(address.replace(/^https:\/\//, '')) || /(?:^|\/)\.{1,2}(?:\/|$)/.test(address))
14
+ throw new Error('Use an HTTPS hostname or subfolder without credentials, ports, a query, or fragment.');
15
+ const basePath = url.pathname.replace(/\/+$/, '');
16
+ return { kind: basePath ? 'subfolder' : 'hostname', hostname: url.hostname, basePath };
17
+ }
18
+ export async function domain(address) {
19
+ const parsed = address && address !== 'remove' ? domainAddress(address) : undefined;
20
+ const token = await publishingToken();
21
+ let { site, binding } = await selectHostingSite(token, false);
22
+ const route = `sites/${site.id}/domain`;
23
+ let document = await request(token, route);
24
+ const connection = () => document.subfolder || document.domain;
25
+ const label = () => document.subfolder ? `${document.subfolder.hostname}${document.subfolder.basePath}` : document.domain?.hostname;
26
+ const mutate = async (action) => {
27
+ document = await request(token, route, { action, kind: document.subfolder ? 'subfolder' : 'hostname', connectionId: connection()?.id });
28
+ };
29
+ let removingSubfolder = false;
30
+ if (address === 'remove') {
31
+ if (!connection()) {
32
+ console.log('No custom address is connected.');
33
+ return;
34
+ }
35
+ if (!await confirm(`Disconnect ${label()}?`))
36
+ return;
37
+ removingSubfolder = !!document.subfolder;
38
+ await mutate('remove');
39
+ await finishRemoval();
40
+ return;
41
+ }
42
+ if (parsed) {
43
+ if (connection()) {
44
+ const same = parsed.kind === 'subfolder' ? document.subfolder?.hostname === parsed.hostname && document.subfolder.basePath === parsed.basePath
45
+ : !document.subfolder && document.domain?.hostname === parsed.hostname;
46
+ if (!same)
47
+ throw new Error(`This site already uses ${label()}. Run lettercamp domain remove before connecting another address.`);
48
+ }
49
+ else {
50
+ document = await request(token, route, { action: 'add', ...parsed });
51
+ binding = { ...binding, provider: undefined };
52
+ await saveBinding(binding);
53
+ }
54
+ }
55
+ if (!connection()) {
56
+ const current = await status(token, site.id);
57
+ console.log(`Site address: ${current.site.url}\nConnect an address with lettercamp domain blog.example.com or lettercamp domain example.com/blog.`);
58
+ return;
59
+ }
60
+ if (connection()?.status === 'removing') {
61
+ removingSubfolder = !!document.subfolder;
62
+ await finishRemoval();
63
+ return;
64
+ }
65
+ if (connection()?.status === 'active') {
66
+ await mutate('refresh');
67
+ if (connection()?.error)
68
+ throw new Error(connection().error);
69
+ }
70
+ if (connection()?.status === 'active') {
71
+ await finishAddress();
72
+ console.log(`Connected: https://${label()}/`);
73
+ return;
74
+ }
75
+ if (document.subfolder?.status === 'verifying') {
76
+ const providers = ['vercel', 'cloudflare', 'nginx'];
77
+ const provider = binding.provider ?? providers[await choose(`Where is ${document.subfolder.hostname} hosted?`, ['Vercel', 'Cloudflare', 'nginx'])];
78
+ binding = { ...binding, provider };
79
+ await saveBinding(binding);
80
+ if (!document.proxy)
81
+ throw new Error('Proxy configuration is unavailable. Run lettercamp domain again.');
82
+ console.log(document.proxy[provider]);
83
+ console.error(provider === 'vercel' ? 'Merge these rewrites into your existing website’s vercel.json, before catch-all rules, and deploy that website.'
84
+ : provider === 'nginx' ? 'Add these locations to the existing HTTPS server block, then reload nginx. Keep proxy_pass URLs without a trailing slash.'
85
+ : `Deploy this Worker and add a Worker route for ${document.subfolder.hostname}${document.subfolder.basePath}* on your existing proxied domain.`);
86
+ console.error('Keep your website’s DNS and HTTPS settings. The proxy must preserve the subfolder path.');
87
+ await ask('Press Enter after applying the proxy configuration');
88
+ }
89
+ else if (document.domain) {
90
+ printDns();
91
+ console.error('Add these records at your DNS provider. Keep the CNAME unproxied (DNS only).');
92
+ await ask('Press Enter after adding the DNS records');
93
+ }
94
+ if (connection()?.error && document.subfolder?.status === 'building') {
95
+ console.error(connection().error);
96
+ if (!await confirm('Retry publishing this address?'))
97
+ return;
98
+ }
99
+ let records = JSON.stringify(document.domain?.records.map(record => [record.name, record.value]));
100
+ const deadline = Date.now() + 10 * 60_000;
101
+ let previous = '';
102
+ while (Date.now() < deadline) {
103
+ await mutate('refresh');
104
+ const nextRecords = JSON.stringify(document.domain?.records.map(record => [record.name, record.value]));
105
+ if (nextRecords !== records) {
106
+ printDns();
107
+ records = nextRecords;
108
+ }
109
+ const current = connection();
110
+ if (!current)
111
+ throw new Error('The address was removed. Run lettercamp domain again to check your site.');
112
+ const message = `${current.status}${current.error ? `: ${current.error}` : ''}`;
113
+ if (message !== previous) {
114
+ console.error(message);
115
+ previous = message;
116
+ }
117
+ if (current.status === 'active') {
118
+ await finishAddress();
119
+ console.log(`Live at https://${label()}/`);
120
+ return;
121
+ }
122
+ if (document.subfolder?.status === 'building') {
123
+ const publishing = await status(token, site.id);
124
+ if (publishing.pending) {
125
+ await waitForDeployment(token, site.id, publishing.pending);
126
+ document = await request(token, route);
127
+ if (connection()?.status === 'active') {
128
+ console.log(`Live at https://${label()}/`);
129
+ return;
130
+ }
131
+ }
132
+ else if (current.error)
133
+ throw new Error(`${current.error} Run lettercamp domain to retry.`);
134
+ }
135
+ await delay(15_000);
136
+ }
137
+ throw new Error('Setup is still pending. Run lettercamp domain to check again; your setup has been saved.');
138
+ async function finishAddress() {
139
+ const deadline = Date.now() + 10 * 60_000;
140
+ while (Date.now() < deadline) {
141
+ const publishing = await status(token, site.id);
142
+ if (publishing.pending) {
143
+ await waitForDeployment(token, site.id, publishing.pending);
144
+ continue;
145
+ }
146
+ if (!publishing.addressPending) {
147
+ if (publishing.activity?.status === 'failed' && publishing.activity.summary === 'Updated site domain') {
148
+ console.error(publishing.activity.error || 'The address rebuild failed.');
149
+ if (!await confirm('Retry publishing this address?'))
150
+ throw new Error('The address is connected, but its rebuild did not finish. Run lettercamp domain to retry.');
151
+ await request(token, `sites/${site.id}/status`, { action: 'retry' });
152
+ continue;
153
+ }
154
+ return;
155
+ }
156
+ await delay(3000);
157
+ }
158
+ throw new Error('The address is connected and waiting to rebuild. Run lettercamp domain to check again.');
159
+ }
160
+ function printDns() {
161
+ if (!document.domain)
162
+ return;
163
+ console.log(`DNS records for ${document.domain.hostname}:\n\nTYPE\tNAME\tVALUE`);
164
+ console.log(`CNAME\t${document.domain.hostname}\t${document.cnameTarget}`);
165
+ for (const record of document.domain.records)
166
+ console.log(`${record.type}\t${record.name}\t${record.value}`);
167
+ }
168
+ async function finishRemoval() {
169
+ const deadline = Date.now() + 10 * 60_000;
170
+ while (Date.now() < deadline) {
171
+ const publishing = await status(token, site.id);
172
+ if (publishing.pending)
173
+ await waitForDeployment(token, site.id, publishing.pending);
174
+ document = await request(token, route);
175
+ if (!connection()) {
176
+ await finishAddress();
177
+ console.log(`Site address: ${publishing.site.defaultUrl}`);
178
+ binding = { ...binding, provider: undefined };
179
+ await saveBinding(binding);
180
+ if (removingSubfolder)
181
+ console.log('You can now remove the proxy rules from your existing website.');
182
+ return;
183
+ }
184
+ if (connection()?.error)
185
+ throw new Error(`${connection().error} Run lettercamp domain remove to retry.`);
186
+ await delay(5000);
187
+ }
188
+ throw new Error('Disconnection is still pending. Run lettercamp domain to check again.');
189
+ }
190
+ }
@@ -0,0 +1,132 @@
1
+ import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { apiOrigin, apiRequest, credentials, responseJson, login, projectPath, getBinding, saveBinding } from './account.js';
4
+ import { ask, choose } from './prompts.js';
5
+ export const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6
+ export class ApiError extends Error {
7
+ status;
8
+ constructor(message, status) {
9
+ super(message);
10
+ this.status = status;
11
+ }
12
+ }
13
+ export async function request(token, route, body) {
14
+ const response = await apiRequest(`/api/cli/${route}`, body === undefined ? {} : {
15
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
16
+ }, token, 120_000);
17
+ if (!response.ok) {
18
+ const result = await response.json().catch(() => ({}));
19
+ throw new ApiError(result.message || result.error || `Lettercamp returned HTTP ${response.status}.`, response.status);
20
+ }
21
+ return responseJson(response);
22
+ }
23
+ export async function publishingToken() {
24
+ let saved;
25
+ try {
26
+ saved = await credentials();
27
+ }
28
+ catch { /* First use starts browser login below. */ }
29
+ if (saved) {
30
+ const response = await apiRequest('/api/cli/session', {}, saved.token);
31
+ if (response.ok) {
32
+ const session = await responseJson(response);
33
+ if (session.canPublish)
34
+ return saved.token;
35
+ console.error('Sign in once more to authorize publishing and domain management.');
36
+ }
37
+ else if (response.status !== 401)
38
+ await responseJson(response);
39
+ }
40
+ await login({ browser: true });
41
+ return (await credentials()).token;
42
+ }
43
+ export async function selectHostingSite(token, allowCreate) {
44
+ let binding = await getBinding();
45
+ if (binding?.creation) {
46
+ try {
47
+ const { site } = await request(token, 'sites', binding.creation);
48
+ binding = { ...binding, siteId: site.id, creation: undefined };
49
+ await saveBinding(binding);
50
+ return { site, binding };
51
+ }
52
+ catch (error) {
53
+ if (error instanceof ApiError && error.status === 400)
54
+ await clearCreation(binding);
55
+ throw error;
56
+ }
57
+ }
58
+ const { sites: all } = await request(token, 'sites');
59
+ const sites = all.filter(site => site.publishingTarget === 'cloud');
60
+ const existing = binding && sites.find(site => site.id === binding?.siteId);
61
+ if (existing && binding)
62
+ return { site: existing, binding };
63
+ if (binding)
64
+ console.error('The saved site is unavailable for cloud publishing. Choose another site for this folder.');
65
+ if (!allowCreate && !sites.length)
66
+ throw new Error('Run lettercamp deploy first to create a site.');
67
+ const choices = [...(allowCreate ? ['Create a new site'] : []), ...sites.map(site => `${site.name} (${site.slug}.lettercamp.site)`)];
68
+ const selected = choices.length === 1 && !allowCreate && !binding ? 0 : await choose('Choose a site for this folder', choices);
69
+ if (allowCreate && selected === 0) {
70
+ const { workspaces } = await request(token, 'workspaces');
71
+ if (!workspaces.length)
72
+ throw new Error('No accessible workspace was found for this account.');
73
+ const workspace = workspaces[workspaces.length === 1 ? 0 : await choose('Choose a workspace', workspaces.map(workspace => workspace.name))];
74
+ const folder = path.basename(await projectPath());
75
+ const suggested = folder.replace(/[^a-z0-9 ]/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'My Blog';
76
+ const name = await ask('Site name', suggested);
77
+ const slug = await ask('Site address (the part before .lettercamp.site)', folder.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '') || 'my-blog');
78
+ const id = randomUUID();
79
+ binding = { origin: apiOrigin(), siteId: id, fresh: true, creation: { id, workspaceId: workspace.id, name, slug } };
80
+ await saveBinding(binding);
81
+ try {
82
+ const { site } = await request(token, 'sites', binding.creation);
83
+ binding = { ...binding, creation: undefined };
84
+ await saveBinding(binding);
85
+ console.error(`Created ${site.slug}.lettercamp.site.`);
86
+ return { site, binding };
87
+ }
88
+ catch (error) {
89
+ // Definitive validation failures created nothing. Preserve uncertain requests for retry.
90
+ if (error instanceof ApiError && error.status === 400)
91
+ await clearCreation(binding);
92
+ throw error;
93
+ }
94
+ }
95
+ const site = sites[selected - (allowCreate ? 1 : 0)];
96
+ binding = { origin: apiOrigin(), siteId: site.id };
97
+ await saveBinding(binding);
98
+ return { site, binding };
99
+ }
100
+ async function clearCreation(binding) {
101
+ // Keep other projects' bindings, but remove this unsuccessful selection.
102
+ const { readConfig, saveConfig } = await import('./account.js');
103
+ const bindings = await readConfig('sites.json') ?? {};
104
+ const key = await projectPath();
105
+ if (bindings[key]?.siteId === binding.siteId)
106
+ delete bindings[key];
107
+ await saveConfig('sites.json', bindings);
108
+ }
109
+ export const status = (token, siteId, deployment) => request(token, `sites/${siteId}/status${deployment ? `?deployment=${encodeURIComponent(deployment)}` : ''}`);
110
+ export async function waitForDeployment(token, siteId, id, onProgress) {
111
+ const deadline = Date.now() + 46 * 60_000;
112
+ let previous = '';
113
+ while (Date.now() < deadline) {
114
+ const result = await status(token, siteId, id);
115
+ const activity = result.activity;
116
+ if (!activity)
117
+ throw new Error('Deployment not found. Run lettercamp deploy again to resume your upload.');
118
+ if (onProgress)
119
+ await onProgress(activity);
120
+ const progress = `${activity.status}:${activity.stage}`;
121
+ if (progress !== previous) {
122
+ console.error(`${activity.stage}…`);
123
+ previous = progress;
124
+ }
125
+ if (activity.status === 'succeeded')
126
+ return result;
127
+ if (activity.status === 'failed' || activity.status === 'cancelled')
128
+ throw new Error(activity.error || `Deployment ${activity.status}. Run lettercamp deploy to retry.`);
129
+ await delay(3000);
130
+ }
131
+ throw new Error('Publishing is still in progress. Run lettercamp deploy again to check it.');
132
+ }
@@ -1,8 +1,8 @@
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.22.0')) {
5
+ throw new Error(`Node.js 22.22 or newer is required (you have ${process.versions.node}).`);
6
6
  }
7
7
  const { run } = await import('./cli.js');
8
8
  await run();
@@ -0,0 +1,154 @@
1
+ import { open, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { imageMetadata } from 'astro/assets/utils';
4
+ import { fromHtml } from 'hast-util-from-html';
5
+ import { toHtml } from 'hast-util-to-html';
6
+ const positive = (value) => Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : undefined;
7
+ const maxProbeBytes = 1024 * 1024;
8
+ // Derive dimensions in the build, so visitors receive reserved space in the HTML.
9
+ // Probe only publication files and our asset origin; arbitrary external embeds
10
+ // retain their authored dimensions and never cause new server-side requests.
11
+ export function mediaDimensions(options) {
12
+ const cache = new Map();
13
+ async function probe(src, file) {
14
+ try {
15
+ if (/^(?:[a-z][\w+.-]*:|\/\/)/i.test(src)) {
16
+ const url = new URL(src);
17
+ if (url.origin !== 'https://assets.lettercamp.com' || url.username || url.password)
18
+ return;
19
+ const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(5000) });
20
+ if (!response.body)
21
+ return;
22
+ if (!response.ok) {
23
+ await response.body.cancel();
24
+ return;
25
+ }
26
+ const reader = response.body.getReader();
27
+ let bytes = new Uint8Array();
28
+ try {
29
+ while (bytes.length < maxProbeBytes) {
30
+ const { done, value } = await reader.read();
31
+ if (done)
32
+ break;
33
+ const next = new Uint8Array(Math.min(maxProbeBytes, bytes.length + value.length));
34
+ next.set(bytes);
35
+ next.set(value.subarray(0, next.length - bytes.length), bytes.length);
36
+ bytes = next;
37
+ try {
38
+ return await imageMetadata(bytes, src);
39
+ }
40
+ catch { /* Read until the header is complete. */ }
41
+ }
42
+ }
43
+ finally {
44
+ await reader.cancel().catch(() => undefined);
45
+ reader.releaseLock();
46
+ }
47
+ return;
48
+ }
49
+ const pathname = decodeURIComponent(src.split(/[?#]/, 1)[0]);
50
+ const filename = await realpath(pathname.startsWith('/') ? path.resolve(options.root, '.' + pathname) : path.resolve(path.dirname(file.path ?? path.join(options.root, 'pages', 'index.md')), pathname));
51
+ const root = await realpath(options.root);
52
+ if (!filename.startsWith(root + path.sep))
53
+ return;
54
+ const handle = await open(filename, 'r');
55
+ try {
56
+ const bytes = Buffer.alloc(maxProbeBytes);
57
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
58
+ return await imageMetadata(bytes.subarray(0, bytesRead), src);
59
+ }
60
+ finally {
61
+ await handle.close();
62
+ }
63
+ }
64
+ catch {
65
+ return;
66
+ } // Unavailable images must not prevent publication.
67
+ }
68
+ function size(src, file) {
69
+ const key = /^(?:https?:|\/)/.test(src) ? src : `${file.path ?? ''}:${src}`;
70
+ let pending = cache.get(key);
71
+ if (!pending) {
72
+ pending = probe(src, file);
73
+ cache.set(key, pending);
74
+ }
75
+ return pending;
76
+ }
77
+ async function transform(tree, file) {
78
+ const jobs = [];
79
+ const definitions = new Map();
80
+ function findDefinitions(node) {
81
+ if (node.type === 'definition' && node.identifier && node.url)
82
+ definitions.set(node.identifier.toLowerCase(), node.url);
83
+ node.children?.forEach(findDefinitions);
84
+ }
85
+ findDefinitions(tree);
86
+ function visit(node) {
87
+ if (['html', 'raw'].includes(node.type) && node.value && /<img\b/i.test(node.value))
88
+ jobs.push(async () => {
89
+ const fragment = fromHtml(node.value, { fragment: true });
90
+ await transform(fragment, { path: file.path, data: {} });
91
+ // Replace only image tags; parsing an isolated opening <div> must not
92
+ // insert a closing tag ahead of the following Markdown block.
93
+ const replacements = [];
94
+ function collect(element) {
95
+ if (element.type === 'element' && element.tagName === 'img' && element.position?.start.offset !== undefined && element.position.end.offset !== undefined) {
96
+ replacements.push({ start: element.position.start.offset, end: element.position.end.offset, html: toHtml(element) });
97
+ }
98
+ if ('children' in element)
99
+ element.children.forEach(collect);
100
+ }
101
+ collect(fragment);
102
+ for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
103
+ node.value = node.value.slice(0, replacement.start) + replacement.html + node.value.slice(replacement.end);
104
+ }
105
+ });
106
+ const jsx = ['mdxJsxFlowElement', 'mdxJsxTextElement'].includes(node.type) && node.name === 'img';
107
+ const markdown = node.type === 'image' || node.type === 'imageReference';
108
+ if (jsx || markdown || node.type === 'element' && node.tagName === 'img') {
109
+ const properties = jsx ? Object.fromEntries((node.attributes ?? []).filter(a => a.type === 'mdxJsxAttribute').map(a => [a.name, a.value]))
110
+ : markdown ? node.data?.hProperties ?? {} : node.properties ?? {};
111
+ const src = markdown ? node.url ?? definitions.get(node.identifier?.toLowerCase() ?? '') : properties.src;
112
+ const width = positive(properties.width), height = positive(properties.height);
113
+ // Leave dynamic dimensions and explicit sizing under the author's control.
114
+ const dynamic = jsx && node.attributes?.some(a => a.type !== 'mdxJsxAttribute' || ['width', 'height'].includes(a.name ?? '') && typeof a.value !== 'string');
115
+ const customSize = properties.width != null && !width || properties.height != null && !height;
116
+ if (typeof src === 'string' && !(width && height) && !dynamic && !customSize)
117
+ jobs.push(async () => {
118
+ const dimensions = await size(src, file);
119
+ if (!dimensions)
120
+ return;
121
+ const w = width ?? (height ? Math.round(height * dimensions.width / dimensions.height) : dimensions.width);
122
+ const h = height ?? Math.max(1, Math.round(w * dimensions.height / dimensions.width));
123
+ const attrs = { width: w, height: h, ...(!width && !height ? { 'data-media-sized': 'true' } : {}) };
124
+ if (jsx) {
125
+ node.attributes = (node.attributes ?? []).filter(a => !['width', 'height'].includes(a.name ?? ''));
126
+ for (const [name, value] of Object.entries(attrs))
127
+ node.attributes.push({ type: 'mdxJsxAttribute', name, value: String(value) });
128
+ }
129
+ else if (markdown)
130
+ node.data = { ...node.data, hProperties: { ...properties, ...attrs } };
131
+ else
132
+ node.properties = { ...properties, ...attrs };
133
+ });
134
+ }
135
+ node.children?.forEach(visit);
136
+ }
137
+ visit(tree);
138
+ const frontmatter = file.data.astro?.frontmatter;
139
+ const cover = frontmatter?.image ?? frontmatter?.ogImage;
140
+ if (frontmatter && typeof cover === 'string' && cover && !(positive(frontmatter.coverImageWidth) && positive(frontmatter.coverImageHeight)))
141
+ jobs.push(async () => {
142
+ const width = frontmatter.image ? positive(frontmatter.imageWidth) : undefined;
143
+ const height = frontmatter.image ? positive(frontmatter.imageHeight) : undefined;
144
+ const dimensions = width && height ? { width, height } : await size(cover, file);
145
+ if (dimensions) {
146
+ frontmatter.coverImageWidth = dimensions.width;
147
+ frontmatter.coverImageHeight = dimensions.height;
148
+ }
149
+ });
150
+ for (let i = 0; i < jobs.length; i += 4)
151
+ await Promise.all(jobs.slice(i, i + 4).map(job => job()));
152
+ }
153
+ return transform;
154
+ }
package/dist/new.js CHANGED
@@ -35,7 +35,7 @@ export async function createSite(directory) {
35
35
  });
36
36
  }
37
37
  console.log(`\nCreated your publication at ${destination}`);
38
- console.log('Open that directory and run: lettercamp dev');
38
+ console.log('Open that directory and run lettercamp dev to preview, or lettercamp deploy to publish.');
39
39
  }
40
40
  finally {
41
41
  await rm(temporary, { recursive: true, force: true });
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
+ }