-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Expand file tree
/
Copy pathtemplate.ts
More file actions
288 lines (253 loc) · 9.31 KB
/
Copy pathtemplate.ts
File metadata and controls
288 lines (253 loc) · 9.31 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
import fs from 'node:fs';
import path from 'node:path';
import { color } from '@astrojs/cli-kit';
import { downloadTemplate } from '@bluwy/giget-core';
import { error, info, title } from '../messages.js';
import type { Context } from './context.js';
/**
* Removes sections from README content that are marked with HTML template markers.
*
* Template marker format:
* <!-- ASTRO:REMOVE:START -->
* Content to remove
* <!-- ASTRO:REMOVE:END -->
*/
export function removeTemplateMarkerSections(content: string): string {
// Pattern to match HTML template marker sections
const pattern = /<!--\s*ASTRO:REMOVE:START\s*-->[\s\S]*?<!--\s*ASTRO:REMOVE:END\s*-->/gi;
let result = content.replace(pattern, '');
// Clean up extra whitespace that might be left behind
// Replace multiple consecutive newlines with at most 2 newlines
result = result.replace(/\n{3,}/g, '\n\n');
return result;
}
/**
* Processes a template README file by removing template marker sections and
* replacing package manager references.
*/
export function processTemplateReadme(content: string, packageManager: string): string {
// Remove sections marked with template markers
let processed = removeTemplateMarkerSections(content);
// Replace package manager references if not npm
if (packageManager !== 'npm') {
processed = processed
.replace(/\bnpm run\b/g, packageManager)
.replace(/\bnpm\b/g, packageManager);
}
return processed;
}
export async function template(
ctx: Pick<Context, 'template' | 'prompt' | 'yes' | 'dryRun' | 'exit' | 'tasks'>,
) {
if (!ctx.template && ctx.yes) ctx.template = 'basics';
if (ctx.template) {
await info('tmpl', `Using ${color.reset(ctx.template)}${color.dim(' as project template')}`);
} else {
const { template: tmpl } = await ctx.prompt({
name: 'template',
type: 'select',
label: title('tmpl'),
message: 'How would you like to start your new project?',
initial: 'basics',
choices: [
{ value: 'basics', label: 'A basic, helpful starter project', hint: '(recommended)' },
{ value: 'blog', label: 'Use blog template' },
{ value: 'starlight', label: 'Use docs (Starlight) template' },
{ value: 'minimal', label: 'Use minimal (empty) template' },
],
});
ctx.template = tmpl;
}
if (ctx.dryRun) {
await info('--dry-run', `Skipping template copying`);
} else if (ctx.template) {
ctx.tasks.push({
pending: 'Template',
start: 'Template copying...',
end: 'Template copied',
while: () =>
copyTemplate(ctx.template!, ctx as Context).catch((e) => {
if (e instanceof Error) {
error('error', e.message);
process.exit(1);
} else {
error('error', 'Unable to clone template.');
process.exit(1);
}
}),
});
} else {
ctx.exit(1);
}
}
// some files are only needed for online editors when using astro.new. Remove for create-astro installs.
const FILES_TO_REMOVE = ['CHANGELOG.md', '.codesandbox'];
const FILES_TO_UPDATE = {
'package.json': (file: string, overrides: { name: string }) =>
fs.promises.readFile(file, 'utf-8').then((value) => {
// Match first indent in the file or fall back to `\t`
const indent = /(^\s+)/m.exec(value)?.[1] ?? '\t';
const packageJson = JSON.parse(value);
packageJson.name = overrides.name;
delete packageJson.private;
return fs.promises.writeFile(file, JSON.stringify(packageJson, null, indent), 'utf-8');
}),
};
export function generateAgentsMd(): string {
return `## Development
When starting the dev server, use background mode:
\`\`\`
astro dev --background
\`\`\`
Manage the background server with \`astro dev stop\`, \`astro dev status\`, and \`astro dev logs\`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
`;
}
export function getTemplateTarget(tmpl: string, ref = 'latest') {
// Handle Starlight templates
if (tmpl === 'starlight' || tmpl.startsWith('starlight/')) {
const [, starter = 'basics'] = tmpl.split('/');
return `github:withastro/starlight/examples/${starter}`;
}
// Handle third-party templates
if (isThirdPartyTemplate(tmpl)) return tmpl;
// Handle Astro templates
if (ref === 'latest') {
// `latest` ref is specially handled to route to a branch specifically
// to allow faster downloads. Otherwise, giget has to download the entire
// repo and only copy a sub directory
return `github:withastro/astro#examples/${tmpl}`;
} else {
return `github:withastro/astro/examples/${tmpl}#${ref}`;
}
}
export function isThirdPartyTemplate(tmpl: string) {
// A template is considered third-party when it includes a path separator
// (for example `owner/repo` or `github:owner/repo`) and is not one of the
// built-in `starlight` templates (`starlight` / `starlight/<starter>`).
if (tmpl === 'starlight' || tmpl.startsWith('starlight/')) return false;
return tmpl.includes('/');
}
/**
* Workaround for modern-tar normalizing paths to NFD (decomposed Unicode).
* On Linux (ext4 and most filesystems), NFC and NFD are distinct byte sequences,
* so files extracted by modern-tar end up in a separate NFD-encoded directory
* instead of the user's original NFC-encoded directory.
* See: https://github.com/withastro/astro/issues/17381
*/
export async function relocateNFDFiles(cwd: string) {
const resolvedCwd = path.resolve(cwd);
const nfdCwd = resolvedCwd.normalize('NFD');
if (nfdCwd === resolvedCwd) return;
if (!fs.existsSync(nfdCwd)) return;
// Verify NFC and NFD paths point to different directories (same inode = same dir, e.g. on macOS)
try {
const nfcStat = fs.statSync(resolvedCwd);
const nfdStat = fs.statSync(nfdCwd);
if (nfcStat.ino === nfdStat.ino) return;
} catch {
return;
}
// Move all entries from NFD directory to NFC directory
const entries = fs.readdirSync(nfdCwd);
for (const entry of entries) {
fs.renameSync(path.join(nfdCwd, entry), path.join(resolvedCwd, entry));
}
// Remove the now-empty NFD directory and any empty NFD parent directories
let dirToRemove = nfdCwd;
while (dirToRemove !== path.dirname(dirToRemove)) {
try {
fs.rmdirSync(dirToRemove);
dirToRemove = path.dirname(dirToRemove);
} catch {
break;
}
}
}
async function copyTemplate(tmpl: string, ctx: Context) {
const templateTarget = getTemplateTarget(tmpl, ctx.ref);
// Copy
if (!ctx.dryRun) {
try {
await downloadTemplate(templateTarget, {
force: true,
cwd: ctx.cwd,
dir: '.',
});
await relocateNFDFiles(ctx.cwd);
// Process the README file to remove marked sections and update package manager
const readmePath = path.resolve(ctx.cwd, 'README.md');
if (fs.existsSync(readmePath)) {
const readme = fs.readFileSync(readmePath, 'utf8');
const processedReadme = processTemplateReadme(readme, ctx.packageManager);
fs.writeFileSync(readmePath, processedReadme);
}
} catch (err: any) {
// Only remove the directory if it's most likely created by us.
if (ctx.cwd !== '.' && ctx.cwd !== './' && !ctx.cwd.startsWith('../')) {
try {
fs.rmdirSync(ctx.cwd);
} catch (_) {
// Ignore any errors from removing the directory,
// make sure we throw and display the original error.
}
}
if (err.message?.includes('404')) {
throw new Error(`Template ${color.reset(tmpl)} ${color.dim('does not exist!')}`);
}
if (err.message) {
error('error', err.message);
}
try {
// The underlying error is often buried deep in the `cause` property
// This is in a try/catch block in case of weirdnesses in accessing the `cause` property
if ('cause' in err) {
// This is probably included in err.message, but we can log it just in case it has extra info
error('error', err.cause);
if ('cause' in err.cause) {
// Hopefully the actual fetch error message
error('error', err.cause?.cause);
}
}
} catch {}
throw new Error(`Unable to download template ${color.reset(tmpl)}`);
}
if (ctx.ai) {
// Generate AGENTS.md for AI coding agents, with a CLAUDE.md link
const agentsPath = path.resolve(ctx.cwd, 'AGENTS.md');
const claudePath = path.resolve(ctx.cwd, 'CLAUDE.md');
fs.writeFileSync(agentsPath, generateAgentsMd());
try {
fs.symlinkSync('AGENTS.md', claudePath);
} catch {
try {
fs.linkSync(agentsPath, claudePath);
} catch {
// Link creation failed; AGENTS.md still exists
}
}
}
// Post-process in parallel
const removeFiles = FILES_TO_REMOVE.map(async (file) => {
const fileLoc = path.resolve(path.join(ctx.cwd, file));
if (fs.existsSync(fileLoc)) {
return fs.promises.rm(fileLoc, { recursive: true });
}
});
const updateFiles = Object.entries(FILES_TO_UPDATE).map(async ([file, update]) => {
const fileLoc = path.resolve(path.join(ctx.cwd, file));
if (fs.existsSync(fileLoc)) {
return update(fileLoc, { name: ctx.projectName! });
}
});
await Promise.all([...removeFiles, ...updateFiles]);
}
}