@lettercamp/cli 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account.js +20 -8
- package/dist/build.js +21 -5
- package/dist/cli.js +15 -6
- package/dist/code-block.js +50 -0
- package/dist/code-detection.js +24 -0
- package/dist/code-highlight.js +54 -0
- package/dist/custom-layout.js +32 -0
- package/dist/deploy.js +131 -0
- package/dist/dev.js +27 -10
- package/dist/domain.js +190 -0
- package/dist/hosting.js +132 -0
- package/dist/lettercamp.js +1 -4
- package/dist/media-dimensions.js +154 -0
- package/dist/new.js +1 -1
- package/dist/preview.js +1 -1
- package/dist/prompts.js +31 -0
- package/dist/publication-links.js +23 -0
- package/dist/react.js +15 -0
- package/dist/upload.js +40 -10
- package/dist/workspace.js +5 -3
- package/package.json +11 -2
- package/renderer/src/components/AuthorByline.astro +20 -11
- package/renderer/src/components/BlogHome.astro +14 -57
- package/renderer/src/components/Footer.astro +12 -9
- package/renderer/src/components/Header.astro +38 -0
- package/renderer/src/components/MobileMenuIcons.astro +7 -0
- package/renderer/src/components/PostCard.astro +12 -5
- package/renderer/src/components/SocialLink.astro +4 -4
- package/renderer/src/layouts/SiteLayout.astro +70 -51
- package/renderer/src/lib/analytics.ts +21 -0
- package/renderer/src/lib/author-dialog.ts +102 -0
- package/renderer/src/lib/authors.js +18 -0
- package/renderer/src/lib/block-widths.ts +30 -0
- package/renderer/src/lib/branding.js +15 -0
- package/renderer/src/lib/button.js +10 -0
- package/renderer/src/lib/category-routes.js +19 -0
- package/renderer/src/lib/code-block.ts +46 -0
- package/renderer/src/lib/fonts.js +33 -0
- package/renderer/src/lib/home-layout.ts +5 -0
- package/renderer/src/lib/image-radius.ts +5 -0
- package/renderer/src/lib/image-rounding.ts +28 -0
- package/renderer/src/lib/image-widths.ts +13 -0
- package/renderer/src/lib/metadata.js +47 -0
- package/renderer/src/lib/mobile-navigation.ts +76 -0
- package/renderer/src/lib/post-layout.ts +6 -0
- package/renderer/src/lib/post-share.ts +264 -0
- package/renderer/src/lib/post-toc.ts +69 -0
- package/renderer/src/lib/posts.js +14 -6
- package/renderer/src/lib/publication-url.js +14 -0
- package/renderer/src/lib/table-styles.ts +12 -0
- package/renderer/src/lib/urls.js +3 -0
- package/renderer/src/pages/[...slug].astro +21 -11
- package/renderer/src/pages/category/[tag].astro +16 -0
- package/renderer/src/pages/category/index.astro +4 -0
- package/renderer/src/pages/index.astro +1 -1
- package/renderer/src/styles/post-share.css +57 -0
- package/renderer/src/styles/site.css +261 -91
- package/dist/public-assets.js +0 -29
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
|
+
}
|
package/dist/hosting.js
ADDED
|
@@ -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
|
+
}
|
package/dist/lettercamp.js
CHANGED
|
@@ -1,11 +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.17.0')) {
|
|
5
|
-
throw new Error(`Node.js 22.17 or newer is required (you have ${process.versions.node}).`);
|
|
6
|
-
}
|
|
7
4
|
if (!semver.satisfies(process.versions.node, '>=22.22.0')) {
|
|
8
|
-
|
|
5
|
+
throw new Error(`Node.js 22.22 or newer is required (you have ${process.versions.node}).`);
|
|
9
6
|
}
|
|
10
7
|
const { run } = await import('./cli.js');
|
|
11
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
|
|
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/preview.js
CHANGED
|
@@ -39,7 +39,7 @@ export async function preview(directory, options) {
|
|
|
39
39
|
for (const url of server.resolvedUrls.network)
|
|
40
40
|
console.log(` Network: ${url}`);
|
|
41
41
|
console.log(`\nPreviewing ${contentRoot}`);
|
|
42
|
-
console.log('Changes to pages,
|
|
42
|
+
console.log('Changes to pages, components, styles, and settings.json reload automatically.');
|
|
43
43
|
console.log('Press Ctrl+C to stop.\n');
|
|
44
44
|
}
|
|
45
45
|
finally {
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
export async function ask(label, fallback) {
|
|
3
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY)
|
|
4
|
+
throw new Error('This step needs a terminal. Run this command interactively once to finish setup.');
|
|
5
|
+
const readline = createInterface({ input: process.stdin, output: process.stderr });
|
|
6
|
+
const abort = new AbortController();
|
|
7
|
+
readline.on('SIGINT', () => abort.abort());
|
|
8
|
+
try {
|
|
9
|
+
const value = (await readline.question(`${label}${fallback ? ` (${fallback})` : ''}: `, { signal: abort.signal })).trim();
|
|
10
|
+
return value || fallback || '';
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error('Cancelled.');
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
readline.close();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export async function confirm(label) {
|
|
20
|
+
return /^(y|yes)$/i.test(await ask(`${label} [y/N]`));
|
|
21
|
+
}
|
|
22
|
+
export async function choose(label, choices) {
|
|
23
|
+
console.error(`\n${label}`);
|
|
24
|
+
choices.forEach((choice, i) => console.error(` ${i + 1}. ${choice}`));
|
|
25
|
+
for (;;) {
|
|
26
|
+
const value = await ask('Choose a number', '1');
|
|
27
|
+
if (/^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= choices.length)
|
|
28
|
+
return Number(value) - 1;
|
|
29
|
+
console.error(`Choose a number from 1 to ${choices.length}.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Root-relative links in Markdown and literal MDX attributes belong to the publication.
|
|
2
|
+
// Absolute URLs and anchors retain their authored meaning.
|
|
3
|
+
export function publicationLinks(options) {
|
|
4
|
+
const base = options.basePath.replace(/\/+$/, '');
|
|
5
|
+
const prefix = (value) => value.startsWith('/') && !value.startsWith('//') ? base + value : value;
|
|
6
|
+
return (tree) => {
|
|
7
|
+
if (!base)
|
|
8
|
+
return;
|
|
9
|
+
function visit(node) {
|
|
10
|
+
if (['link', 'image', 'definition'].includes(node.type) && node.url)
|
|
11
|
+
node.url = prefix(node.url);
|
|
12
|
+
if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
|
|
13
|
+
for (const attribute of node.attributes ?? []) {
|
|
14
|
+
if (attribute.type === 'mdxJsxAttribute' && ['href', 'src', 'poster'].includes(attribute.name ?? '') && typeof attribute.value === 'string')
|
|
15
|
+
attribute.value = prefix(attribute.value);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const child of node.children ?? [])
|
|
19
|
+
visit(child);
|
|
20
|
+
}
|
|
21
|
+
visit(tree);
|
|
22
|
+
};
|
|
23
|
+
}
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
export const reactIntegrationDirectory = path.dirname(require.resolve('@astrojs/react/package.json'));
|
|
5
|
+
// Astro resolves renderer entrypoints from the temporary workspace. Locate the
|
|
6
|
+
// CLI's integration even when npm puts it in a nested node_modules folder.
|
|
7
|
+
// The integration deduplicates React against the workspace's CLI dependencies.
|
|
8
|
+
export const reactViteConfig = {
|
|
9
|
+
resolve: {
|
|
10
|
+
alias: Object.keys(require('@astrojs/react/package.json').exports).map(entry => {
|
|
11
|
+
const id = '@astrojs/react' + (entry === '.' ? '' : entry.slice(1));
|
|
12
|
+
return { find: new RegExp(`^${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), replacement: require.resolve(id) };
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
};
|
package/dist/upload.js
CHANGED
|
@@ -1,27 +1,57 @@
|
|
|
1
1
|
import { open } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { apiRequest, credentials, responseJson, selectUploadSite } from './account.js';
|
|
4
|
-
|
|
5
|
-
const saved = await credentials();
|
|
4
|
+
async function uploadFile(file, slug, token) {
|
|
6
5
|
const handle = await open(file, 'r');
|
|
7
6
|
let bytes;
|
|
8
7
|
try {
|
|
9
8
|
const info = await handle.stat();
|
|
10
9
|
if (!info.isFile() || !info.size)
|
|
11
|
-
throw new Error('Choose a nonempty image file.');
|
|
10
|
+
throw new Error('Choose a nonempty image or font file.');
|
|
12
11
|
if (info.size > 10 * 1024 * 1024)
|
|
13
|
-
throw new Error('
|
|
12
|
+
throw new Error('Files must be 10 MB or smaller.');
|
|
14
13
|
bytes = await handle.readFile();
|
|
15
14
|
if (bytes.byteLength > 10 * 1024 * 1024)
|
|
16
|
-
throw new Error('
|
|
15
|
+
throw new Error('Files must be 10 MB or smaller.');
|
|
17
16
|
}
|
|
18
17
|
finally {
|
|
19
18
|
await handle.close();
|
|
20
19
|
}
|
|
21
|
-
const
|
|
22
|
-
|
|
20
|
+
const font = /\.(ttf|otf|woff2?)$/i.test(file);
|
|
21
|
+
return responseJson(await apiRequest(`/api/sites/${encodeURIComponent(slug)}/assets${font ? "?type=font" : ""}`, {
|
|
23
22
|
method: 'POST', headers: { 'Content-Type': 'application/octet-stream', 'X-File-Name': encodeURIComponent(path.basename(file)) }, body: new Uint8Array(bytes),
|
|
24
|
-
},
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
}, token));
|
|
24
|
+
}
|
|
25
|
+
export async function upload(files, options) {
|
|
26
|
+
const saved = await credentials();
|
|
27
|
+
const { site, remember } = await selectUploadSite(saved.token, options.site);
|
|
28
|
+
const multiple = files.length > 1;
|
|
29
|
+
const results = [];
|
|
30
|
+
let remembered = false;
|
|
31
|
+
for (const file of files) {
|
|
32
|
+
let result;
|
|
33
|
+
try {
|
|
34
|
+
result = await uploadFile(file, site.slug, saved.token);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (!multiple)
|
|
38
|
+
throw error;
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
40
|
+
results.push({ file, error: message });
|
|
41
|
+
console.error(`${file}: ${message}`);
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
results.push({ file, ...result });
|
|
46
|
+
if (!remembered) {
|
|
47
|
+
remembered = true;
|
|
48
|
+
await remember().catch(() => console.error('Uploaded successfully, but the site selection could not be saved. Use --site on your next upload.'));
|
|
49
|
+
}
|
|
50
|
+
if (!options.json)
|
|
51
|
+
console.log(multiple ? `${file}\t${result.url}` : result.url);
|
|
52
|
+
else if (!multiple)
|
|
53
|
+
console.log(JSON.stringify(result));
|
|
54
|
+
}
|
|
55
|
+
if (options.json && multiple)
|
|
56
|
+
console.log(JSON.stringify(results));
|
|
27
57
|
}
|