@lettercamp/cli 0.1.7 → 0.1.9
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 +22 -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/component-blocks.js +32 -0
- package/dist/custom-layout.js +32 -0
- package/dist/deploy.js +131 -0
- package/dist/dev.js +28 -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 +35 -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/font-fallbacks.js +196 -0
- package/renderer/src/lib/fonts.js +48 -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/account.js
CHANGED
|
@@ -12,7 +12,7 @@ export function apiOrigin() {
|
|
|
12
12
|
}
|
|
13
13
|
return url.origin;
|
|
14
14
|
}
|
|
15
|
-
async function readConfig(name) {
|
|
15
|
+
export async function readConfig(name) {
|
|
16
16
|
try {
|
|
17
17
|
return JSON.parse(await readFile(path.join(configRoot(), name), 'utf8'));
|
|
18
18
|
}
|
|
@@ -22,7 +22,7 @@ async function readConfig(name) {
|
|
|
22
22
|
throw new Error(`Unable to read Lettercamp ${name}. Check ${configRoot()}.`);
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
-
async function saveConfig(name, value) {
|
|
25
|
+
export async function saveConfig(name, value) {
|
|
26
26
|
await mkdir(configRoot(), { recursive: true, mode: 0o700 });
|
|
27
27
|
if (process.platform !== 'win32')
|
|
28
28
|
await chmod(configRoot(), 0o700);
|
|
@@ -42,12 +42,12 @@ export async function credentials() {
|
|
|
42
42
|
}
|
|
43
43
|
return saved;
|
|
44
44
|
}
|
|
45
|
-
export async function apiRequest(route, options = {}, token) {
|
|
45
|
+
export async function apiRequest(route, options = {}, token, timeout = 30_000) {
|
|
46
46
|
const headers = new Headers(options.headers);
|
|
47
47
|
if (token)
|
|
48
48
|
headers.set('Authorization', `Bearer ${token}`);
|
|
49
49
|
try {
|
|
50
|
-
return await fetch(new URL(route, apiOrigin()), { ...options, headers, redirect: 'error', signal: AbortSignal.timeout(
|
|
50
|
+
return await fetch(new URL(route, apiOrigin()), { ...options, headers, redirect: 'error', signal: AbortSignal.timeout(timeout) });
|
|
51
51
|
}
|
|
52
52
|
catch {
|
|
53
53
|
throw new Error('Unable to reach Lettercamp. Check your connection and try again.');
|
|
@@ -81,7 +81,7 @@ async function openBrowser(url) {
|
|
|
81
81
|
export async function login(options) {
|
|
82
82
|
const previous = await readConfig('credentials.json');
|
|
83
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 }) }));
|
|
84
|
+
const login = await responseJson(await apiRequest('/api/cli/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, publishing: true }) }));
|
|
85
85
|
const verification = new URL(login.verification_uri_complete);
|
|
86
86
|
if (verification.origin !== apiOrigin() || verification.pathname !== '/cli/login')
|
|
87
87
|
throw new Error('Lettercamp returned an unexpected login address.');
|
|
@@ -149,7 +149,7 @@ export async function logout() {
|
|
|
149
149
|
await rm(path.join(configRoot(), 'credentials.json'), { force: true });
|
|
150
150
|
console.log('Signed out.');
|
|
151
151
|
}
|
|
152
|
-
async function projectPath() {
|
|
152
|
+
export async function projectPath() {
|
|
153
153
|
const cwd = await realpath(process.cwd());
|
|
154
154
|
for (let directory = cwd;; directory = path.dirname(directory)) {
|
|
155
155
|
try {
|
|
@@ -170,8 +170,20 @@ export async function selectUploadSite(token, requested) {
|
|
|
170
170
|
const site = selected ? sites.find(item => item.id === selected || item.slug === selected) : sites.length === 1 ? sites[0] : undefined;
|
|
171
171
|
if (!site) {
|
|
172
172
|
if (!sites.length)
|
|
173
|
-
throw new Error('No accessible sites.
|
|
173
|
+
throw new Error('No accessible sites. Run lettercamp deploy to create one.');
|
|
174
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
175
|
}
|
|
176
|
-
return { site, remember: () =>
|
|
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 });
|
|
177
189
|
}
|
package/dist/build.js
CHANGED
|
@@ -1,37 +1,54 @@
|
|
|
1
|
+
import { publicationLinks } from './publication-links.js';
|
|
2
|
+
import { codeBlocks } from "./code-highlight.js";
|
|
3
|
+
import { componentBlocks } from "./component-blocks.js";
|
|
1
4
|
import path from 'node:path';
|
|
2
5
|
import { build } from 'astro';
|
|
3
6
|
import mdx from '@astrojs/mdx';
|
|
4
7
|
import { unified } from '@astrojs/markdown-remark';
|
|
5
8
|
import { prepareWorkspace } from './workspace.js';
|
|
6
9
|
import { readingTime } from './reading-time.js';
|
|
10
|
+
import { mediaDimensions } from './media-dimensions.js';
|
|
11
|
+
import { reactViteConfig } from './react.js';
|
|
7
12
|
export async function buildSite(directory, options) {
|
|
8
13
|
const origin = new URL(options.site);
|
|
9
14
|
if (!['https:', 'http:'].includes(origin.protocol))
|
|
10
15
|
throw new Error('Use an HTTP or HTTPS site URL.');
|
|
16
|
+
if (origin.username || origin.password || origin.search || origin.hash)
|
|
17
|
+
throw new Error('Use a site URL without credentials, query parameters, or a fragment.');
|
|
11
18
|
const root = path.resolve(directory);
|
|
12
19
|
const outDir = path.resolve(root, options.outDir ?? 'dist');
|
|
13
20
|
const overlaps = (a, b) => a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep);
|
|
14
|
-
if (outDir === root || root.startsWith(outDir + path.sep) || ['pages', 'public'].some(name => overlaps(outDir, path.join(root, name)))) {
|
|
21
|
+
if (outDir === root || root.startsWith(outDir + path.sep) || ['pages', 'public', 'components'].some(name => overlaps(outDir, path.join(root, name)))) {
|
|
15
22
|
throw new Error('Output must not replace the publication or its source files.');
|
|
16
23
|
}
|
|
17
24
|
const { workspace, cleanup } = await prepareWorkspace(root);
|
|
18
25
|
const previousCwd = process.cwd();
|
|
26
|
+
const previousNodeEnv = process.env.NODE_ENV;
|
|
19
27
|
try {
|
|
28
|
+
// The React integration imports react-dom to detect its version. Select the
|
|
29
|
+
// production runtime before loading it, as Astro's CLI does before a build.
|
|
30
|
+
process.env.NODE_ENV = 'production';
|
|
31
|
+
const { default: react } = await import('@astrojs/react');
|
|
20
32
|
// Astro 7 places temporary prerender modules relative to cwd when outDir
|
|
21
33
|
// is external. Keep them beside the renderer's node_modules, not the content.
|
|
22
34
|
process.chdir(workspace);
|
|
23
35
|
await build({
|
|
24
|
-
root: workspace, configFile: false, site: origin.origin,
|
|
36
|
+
root: workspace, configFile: false, site: origin.origin, base: origin.pathname, trailingSlash: 'always',
|
|
25
37
|
cacheDir: path.join(workspace, '.cache'),
|
|
26
|
-
publicDir: path.join(workspace, 'public'), outDir, output: 'static',
|
|
27
|
-
integrations: [mdx()],
|
|
28
|
-
|
|
38
|
+
publicDir: path.join(workspace, '.lettercamp-no-public'), outDir, output: 'static',
|
|
39
|
+
integrations: [mdx(), react()],
|
|
40
|
+
vite: reactViteConfig,
|
|
41
|
+
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root }], [publicationLinks, { basePath: origin.pathname }]], rehypePlugins: [[mediaDimensions, { root }]] }) },
|
|
29
42
|
build: { format: 'directory', ...(options.assetsPrefix ? { assetsPrefix: options.assetsPrefix } : {}) },
|
|
30
43
|
});
|
|
31
44
|
return outDir;
|
|
32
45
|
}
|
|
33
46
|
finally {
|
|
34
47
|
process.chdir(previousCwd);
|
|
48
|
+
if (previousNodeEnv === undefined)
|
|
49
|
+
delete process.env.NODE_ENV;
|
|
50
|
+
else
|
|
51
|
+
process.env.NODE_ENV = previousNodeEnv;
|
|
35
52
|
await cleanup();
|
|
36
53
|
}
|
|
37
54
|
}
|
package/dist/cli.js
CHANGED
|
@@ -4,10 +4,10 @@ import { preview } from './preview.js';
|
|
|
4
4
|
export async function run(argv = process.argv) {
|
|
5
5
|
const program = new Command()
|
|
6
6
|
.name('lettercamp')
|
|
7
|
-
.description('Create and
|
|
7
|
+
.description('Create, preview, and publish your Lettercamp publication.')
|
|
8
8
|
.version(version, '-v, --version', 'Show the installed CLI version');
|
|
9
9
|
program.command('login')
|
|
10
|
-
.description('Sign in through your browser to
|
|
10
|
+
.description('Sign in through your browser to publish and manage your site')
|
|
11
11
|
.option('--no-browser', 'Print the approval link without opening a browser')
|
|
12
12
|
.option('--name <name>', 'Name for this CLI session')
|
|
13
13
|
.action(async (options) => { const { login } = await import('./account.js'); await login(options); });
|
|
@@ -19,11 +19,12 @@ export async function run(argv = process.argv) {
|
|
|
19
19
|
.description('Revoke this CLI session and remove its local credential')
|
|
20
20
|
.action(async () => { const { logout } = await import('./account.js'); await logout(); });
|
|
21
21
|
program.command('upload')
|
|
22
|
-
.description('Upload
|
|
23
|
-
.argument('<
|
|
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
24
|
.option('--site <slug>', 'Destination site (remembered for this local publication)')
|
|
25
|
-
.option('--json', 'Print
|
|
26
|
-
.
|
|
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); });
|
|
27
28
|
program.command('new')
|
|
28
29
|
.description('Download the starter into a new or empty directory')
|
|
29
30
|
.argument('[directory]', 'Destination directory', 'my-lettercamp')
|
|
@@ -31,6 +32,14 @@ export async function run(argv = process.argv) {
|
|
|
31
32
|
const { createSite } = await import('./new.js');
|
|
32
33
|
await createSite(directory);
|
|
33
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); });
|
|
34
43
|
program.command('version')
|
|
35
44
|
.description('Show the installed and latest published CLI versions')
|
|
36
45
|
.action(showVersion);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Generated from lettercamp/lib/code-block.ts.
|
|
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.
|
|
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.
|
|
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
|
+
// Give standalone components a real layout container without passing editor
|
|
2
|
+
// props to user code. Keep component children opaque and inline JSX inline.
|
|
3
|
+
export function componentBlocks() {
|
|
4
|
+
return (tree) => {
|
|
5
|
+
const visit = (parent) => {
|
|
6
|
+
if (!parent.children)
|
|
7
|
+
return;
|
|
8
|
+
parent.children = parent.children.map(node => {
|
|
9
|
+
if (node.type === "mdxJsxFlowElement") {
|
|
10
|
+
if (Array.isArray(node.attributes) && node.attributes.some(attribute => attribute.type === "mdxJsxAttribute" && attribute.name === "data-lettercamp-component"))
|
|
11
|
+
return node;
|
|
12
|
+
if (!node.name || !/^[a-z][a-z0-9-]*$/.test(node.name))
|
|
13
|
+
return wrap(node);
|
|
14
|
+
}
|
|
15
|
+
else if (node.type === "mdxFlowExpression" && node.data?.estree?.body.length)
|
|
16
|
+
return wrap(node);
|
|
17
|
+
else if (node.type !== "mdxJsxTextElement")
|
|
18
|
+
visit(node);
|
|
19
|
+
return node;
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
visit(tree);
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function wrap(node) {
|
|
26
|
+
return {
|
|
27
|
+
type: "mdxJsxFlowElement", name: "div",
|
|
28
|
+
attributes: [{ type: "mdxJsxAttribute", name: "data-lettercamp-component", value: "" },
|
|
29
|
+
{ type: "mdxJsxAttribute", name: "data-block-width", value: "regular" }],
|
|
30
|
+
children: [node],
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/dev.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
|
+
import { codeBlocks } from "./code-highlight.js";
|
|
2
|
+
import { componentBlocks } from "./component-blocks.js";
|
|
1
3
|
import { rm } from 'node:fs/promises';
|
|
2
4
|
import path from 'node:path';
|
|
3
5
|
import { dev } from 'astro';
|
|
4
6
|
import mdx from '@astrojs/mdx';
|
|
7
|
+
import react from '@astrojs/react';
|
|
5
8
|
import { unified } from '@astrojs/markdown-remark';
|
|
6
9
|
import { prepareWorkspace } from './workspace.js';
|
|
7
10
|
import { selectDefaultPort } from './ports.js';
|
|
8
11
|
import { readingTime } from './reading-time.js';
|
|
9
|
-
import {
|
|
12
|
+
import { mediaDimensions } from './media-dimensions.js';
|
|
13
|
+
import { reactIntegrationDirectory, reactViteConfig } from './react.js';
|
|
14
|
+
import { writeCustomLayout } from './custom-layout.js';
|
|
10
15
|
export async function startDev(directory, options) {
|
|
11
16
|
// Astro is bundled with Lettercamp; users update it through the Lettercamp CLI.
|
|
12
17
|
process.env.ASTRO_DISABLE_UPDATE_CHECK = 'true';
|
|
@@ -44,13 +49,14 @@ export async function startDev(directory, options) {
|
|
|
44
49
|
root: workspace,
|
|
45
50
|
logLevel: 'warn',
|
|
46
51
|
configFile: false,
|
|
47
|
-
publicDir: path.join(workspace, 'public'),
|
|
52
|
+
publicDir: path.join(workspace, '.lettercamp-no-public'),
|
|
48
53
|
output: 'static',
|
|
49
|
-
integrations: [mdx()],
|
|
50
|
-
markdown: { processor: unified({ remarkPlugins: [readingTime] }) },
|
|
54
|
+
integrations: [mdx(), react()],
|
|
55
|
+
markdown: { syntaxHighlight: false, processor: unified({ remarkPlugins: [readingTime, codeBlocks, componentBlocks, [mediaDimensions, { root: contentRoot }]], rehypePlugins: [[mediaDimensions, { root: contentRoot }]] }) },
|
|
51
56
|
devToolbar: { enabled: false },
|
|
52
57
|
server: { port, host: options.host, open: options.open },
|
|
53
58
|
vite: {
|
|
59
|
+
...reactViteConfig,
|
|
54
60
|
clearScreen: false,
|
|
55
61
|
logLevel: 'warn',
|
|
56
62
|
plugins: [{
|
|
@@ -70,15 +76,27 @@ export async function startDev(directory, options) {
|
|
|
70
76
|
});
|
|
71
77
|
// Imported files are watched automatically, but external folders
|
|
72
78
|
// must be registered for newly created pages to be discovered.
|
|
73
|
-
|
|
74
|
-
vite.watcher.
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
const components = path.join(contentRoot, 'components');
|
|
80
|
+
vite.watcher.add([path.join(contentRoot, 'pages'), path.join(contentRoot, 'settings.json'), components]);
|
|
81
|
+
let updatingLayout = Promise.resolve();
|
|
82
|
+
const updateLayout = (file) => {
|
|
83
|
+
if (path.dirname(file) !== components || !/^(Header|Footer)\.(jsx|tsx)$/.test(path.basename(file)))
|
|
84
|
+
return;
|
|
85
|
+
updatingLayout = updatingLayout.then(async () => {
|
|
86
|
+
await writeCustomLayout(workspace, contentRoot);
|
|
87
|
+
// Also clear an earlier duplicate-file error when the valid
|
|
88
|
+
// slot source did not change. Add/remove is a rare full reload.
|
|
89
|
+
vite.moduleGraph.invalidateAll();
|
|
90
|
+
vite.ws.send({ type: 'full-reload' });
|
|
91
|
+
}).catch(error => {
|
|
92
|
+
vite.config.logger.error(error.message);
|
|
93
|
+
vite.ws.send({ type: 'error', err: { message: error.message, stack: '' } });
|
|
77
94
|
});
|
|
78
|
-
}
|
|
95
|
+
};
|
|
96
|
+
vite.watcher.on('add', updateLayout).on('unlink', updateLayout);
|
|
79
97
|
},
|
|
80
98
|
}],
|
|
81
|
-
server: { strictPort: false, fs: { allow: [workspace, contentRoot, modules] } },
|
|
99
|
+
server: { strictPort: false, fs: { allow: [workspace, contentRoot, modules, reactIntegrationDirectory] } },
|
|
82
100
|
},
|
|
83
101
|
});
|
|
84
102
|
process.once('SIGINT', stop);
|