@transclude/create 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Joe Dakroub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/index.js ADDED
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ // Starts a project. `npm create transclude my-app`, or `npx create-transclude`.
3
+ //
4
+ // It copies a template and rewrites three things: the package name, the
5
+ // dependency on this framework, and the title on the page. Nothing else is
6
+ // generated, so what lands is what is in `templates/`, and reading that
7
+ // directory tells you exactly what a new project is.
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import readline from 'node:readline/promises';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ // `fileURLToPath`, never `url.pathname`: a space in the path stays
15
+ // percent-encoded in the second one, and `Atelier%20Dakroub` is not a directory.
16
+ const root = path.dirname(fileURLToPath(import.meta.url));
17
+ const templates = path.join(root, 'templates');
18
+
19
+ const TEMPLATES = [
20
+ { name: 'minimal', what: 'a layout, two pages, a 404 and a stylesheet' },
21
+ { name: 'blank', what: 'one page, and nothing else' },
22
+ ];
23
+
24
+ /** npm refuses some of what a directory name allows, and so does this. */
25
+ const NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
26
+
27
+ function usage() {
28
+ const list = TEMPLATES.map((t) => ` ${t.name.padEnd(9)} ${t.what}`).join('\n');
29
+ return [
30
+ 'Usage: create-transclude [directory] [options]',
31
+ '',
32
+ ' --template <name> which files to start from',
33
+ `${list}`,
34
+ ' --yes take the defaults and ask nothing',
35
+ ' --link depend on a local checkout, for working on the framework',
36
+ '',
37
+ ].join('\n');
38
+ }
39
+
40
+ /** Arguments, in the shape the rest of the file wants them. */
41
+ function parse(argv) {
42
+ const options = { dir: null, template: null, yes: false, link: false };
43
+
44
+ for (let i = 0; i < argv.length; i += 1) {
45
+ const arg = argv[i];
46
+ if (arg === '--yes' || arg === '-y') options.yes = true;
47
+ else if (arg === '--link') options.link = true;
48
+ else if (arg === '--help' || arg === '-h') options.help = true;
49
+ else if (arg === '--template' || arg === '-t') options.template = argv[++i];
50
+ else if (arg.startsWith('--template=')) options.template = arg.slice('--template='.length);
51
+ else if (arg.startsWith('-')) throw new Error(`unknown option ${arg}`);
52
+ else if (options.dir === null) options.dir = arg;
53
+ else throw new Error(`unexpected argument ${arg}`);
54
+ }
55
+ return options;
56
+ }
57
+
58
+ /**
59
+ * Every file under `dir`, relative to it.
60
+ *
61
+ * `node_modules` and `dist` are skipped so running this from a directory that
62
+ * was used once does not copy a build into a new project.
63
+ */
64
+ function walk(dir, base = dir, out = []) {
65
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
66
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue;
67
+ const full = path.join(dir, entry.name);
68
+ if (entry.isDirectory()) walk(full, base, out);
69
+ else out.push(path.relative(base, full));
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * What a directory can be written into.
76
+ *
77
+ * An existing directory is fine when it holds nothing that would be
78
+ * overwritten; a `.git` from `git init` is the ordinary case. Anything else is
79
+ * refused rather than merged, because a half-copied project is worse to
80
+ * untangle than one that never started.
81
+ */
82
+ function checkTarget(dir) {
83
+ if (!fs.existsSync(dir)) return;
84
+ const held = fs.readdirSync(dir).filter((name) => name !== '.git' && name !== '.DS_Store');
85
+ if (held.length) {
86
+ throw new Error(`${path.relative(process.cwd(), dir) || '.'} is not empty`);
87
+ }
88
+ }
89
+
90
+ /**
91
+ * What a new project declares.
92
+ *
93
+ * `--link` points at a checkout of the framework, which is the only way to try
94
+ * an unpublished one: the version below is this package's, and the two are
95
+ * released together.
96
+ */
97
+ function dependency(link) {
98
+ if (link) return `file:${path.resolve(root, '..')}`;
99
+ const { version } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
100
+ return `^${version}`;
101
+ }
102
+
103
+ async function ask(options) {
104
+ // A pipe is not a person. Without a terminal the defaults are the only answers
105
+ // available, and hanging on a prompt nobody can see is the worse failure.
106
+ if (options.yes || !process.stdin.isTTY) {
107
+ return { dir: options.dir ?? 'my-app', template: options.template ?? TEMPLATES[0].name };
108
+ }
109
+
110
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
111
+ try {
112
+ let dir = options.dir;
113
+ while (!dir) dir = (await rl.question('Directory (my-app): ')).trim() || 'my-app';
114
+
115
+ let template = options.template;
116
+ if (!template) {
117
+ const names = TEMPLATES.map((t) => t.name).join(', ');
118
+ const answer = (await rl.question(`Template (${names}) [minimal]: `)).trim();
119
+ template = answer || TEMPLATES[0].name;
120
+ }
121
+ return { dir, template };
122
+ } finally {
123
+ rl.close();
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Copies one template, substituting the two placeholders.
129
+ *
130
+ * Only `.json`, `.js`, `.html`, `.css` and `.md` are read as text. Anything else
131
+ * is copied byte for byte, so a template may hold an image without this
132
+ * corrupting it.
133
+ */
134
+ function copy(from, to, replacements) {
135
+ const TEXT = new Set(['.json', '.js', '.html', '.css', '.md', '.txt']);
136
+
137
+ for (const rel of walk(from)) {
138
+ // `.gitignore` in a template would be applied to the template itself by
139
+ // every tool that reads one, including npm when this is packed.
140
+ const target = path.join(to, rel === '_gitignore' ? '.gitignore' : rel);
141
+ fs.mkdirSync(path.dirname(target), { recursive: true });
142
+
143
+ if (!TEXT.has(path.extname(rel))) {
144
+ fs.copyFileSync(path.join(from, rel), target);
145
+ continue;
146
+ }
147
+
148
+ let text = fs.readFileSync(path.join(from, rel), 'utf8');
149
+ for (const [token, value] of Object.entries(replacements)) {
150
+ text = text.split(token).join(value);
151
+ }
152
+ fs.writeFileSync(target, text);
153
+ }
154
+ }
155
+
156
+ async function main() {
157
+ const options = parse(process.argv.slice(2));
158
+ if (options.help) {
159
+ process.stdout.write(usage());
160
+ return;
161
+ }
162
+
163
+ const answers = await ask(options);
164
+ const template = TEMPLATES.find((t) => t.name === answers.template);
165
+ if (!template) {
166
+ const names = TEMPLATES.map((t) => t.name).join(', ');
167
+ throw new Error(`no template called ${JSON.stringify(answers.template)}. There is ${names}.`);
168
+ }
169
+
170
+ const target = path.resolve(process.cwd(), answers.dir);
171
+ checkTarget(target);
172
+
173
+ const name = path.basename(target);
174
+ if (!NAME.test(name)) {
175
+ throw new Error(`${JSON.stringify(name)} is not a usable package name`);
176
+ }
177
+
178
+ copy(path.join(templates, template.name), target, {
179
+ __NAME__: name,
180
+ __TRANSCLUDE__: dependency(options.link),
181
+ });
182
+
183
+ const where = path.relative(process.cwd(), target);
184
+ process.stdout.write(
185
+ [
186
+ `\nCreated ${name} from the ${template.name} template.\n\n`,
187
+ where ? ` cd ${where}\n` : '',
188
+ ' npm install\n',
189
+ ' npm run dev\n\n',
190
+ ].join(''),
191
+ );
192
+ }
193
+
194
+ main().catch((error) => {
195
+ process.stderr.write(`\n${error.message}\n\n${usage()}`);
196
+ process.exitCode = 1;
197
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@transclude/create",
3
+ "version": "0.1.0",
4
+ "description": "Starts a transclude project. `npm create @transclude my-app`.",
5
+ "keywords": [
6
+ "transclude",
7
+ "scaffold",
8
+ "starter",
9
+ "create"
10
+ ],
11
+ "homepage": "https://transclude.dev",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/transclude-dev/transclude.git",
15
+ "directory": "create"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/transclude-dev/transclude/issues"
19
+ },
20
+ "license": "MIT",
21
+ "author": "Joe Dakroub",
22
+ "type": "module",
23
+ "engines": {
24
+ "node": ">=22"
25
+ },
26
+ "bin": {
27
+ "create-transclude": "./index.js"
28
+ },
29
+ "files": [
30
+ "index.js",
31
+ "templates",
32
+ "LICENSE"
33
+ ]
34
+ }
@@ -0,0 +1,19 @@
1
+ # __NAME__
2
+
3
+ ```sh
4
+ npm install
5
+ npm run dev
6
+ ```
7
+
8
+ A page is an `.html` file in `app/routes/`. The directory tree is the route
9
+ table, so `app/routes/about.html` answers `/about`.
10
+
11
+ | | |
12
+ | --- | --- |
13
+ | `npm run dev` | the dev server, with hot reload |
14
+ | `npm run check` | types, from the shapes your loaders return |
15
+ | `npm run preview` | build, then serve the build |
16
+ | `npm run build` | write `dist/` |
17
+
18
+ `dist/static` is self-contained: any static host will serve it. Everything that
19
+ reads a request is served by `npm start`.
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ dist
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ .DS_Store
@@ -0,0 +1,4 @@
1
+ <title>__NAME__</title>
2
+
3
+ <h1>__NAME__</h1>
4
+ <p>Edit <code>app/routes/index.html</code> and this changes.</p>
@@ -0,0 +1,10 @@
1
+ /* Linked by every page. It goes through Vite, so nesting and @import work, and
2
+ the build hashes and compresses it. */
3
+
4
+ body {
5
+ margin: 0 auto;
6
+ padding: 2rem 1rem;
7
+ max-width: 42rem;
8
+ font: 16px/1.6 system-ui, sans-serif;
9
+ color-scheme: light dark;
10
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["esnext", "dom", "dom.iterable"],
7
+ "checkJs": false,
8
+ "noEmit": true
9
+ },
10
+ "include": ["app", "transclude.config.js"]
11
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "node --env-file-if-exists=.env node_modules/.bin/transclude-dev",
7
+ "build": "transclude-build",
8
+ "start": "node --env-file-if-exists=.env node_modules/.bin/transclude-serve",
9
+ "preview": "npm run build && npm start",
10
+ "check": "transclude-check"
11
+ },
12
+ "dependencies": {
13
+ "@transclude/core": "__TRANSCLUDE__"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.9",
17
+ "vite": "^8"
18
+ }
19
+ }
@@ -0,0 +1,27 @@
1
+ // Every path the framework needs is here, relative to `appDir` unless it says
2
+ // otherwise. `npx transclude-check` reads this too.
3
+ //
4
+ // The keys left out have defaults, and the defaults are the quiet ones: no
5
+ // proxy, no feed, no sitemap, no service worker list, no script on any page.
6
+ // See the configuration page in the docs for the whole list.
7
+
8
+ export default {
9
+ appDir: 'app',
10
+ routesDir: 'routes',
11
+ elementsDir: 'elements',
12
+ stylesheet: 'app/styles/global.css',
13
+
14
+ // Dev and production both listen here, so this app has one port. `PORT` in the
15
+ // environment wins.
16
+ port: 1960,
17
+
18
+ // `never` redirects /about/ to /about with a 301, so a page has one URL.
19
+ trailingSlash: 'never',
20
+
21
+ // Signs cookies, which is what makes one usable as a session. Read it from the
22
+ // environment: this file is yours, so where the secret lives is your decision.
23
+ cookieSecret: globalThis.process?.env?.COOKIE_SECRET ?? null,
24
+
25
+ outDir: 'dist',
26
+ typesFile: 'app/transclude-env.d.ts',
27
+ };
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vite';
2
+ import transclude from '@transclude/core';
3
+ import config from './transclude.config.js';
4
+
5
+ export default defineConfig({
6
+ appType: 'custom',
7
+ plugins: [transclude(config)],
8
+ });
@@ -0,0 +1,20 @@
1
+ # __NAME__
2
+
3
+ ```sh
4
+ npm install
5
+ npm run dev
6
+ ```
7
+
8
+ A page is an `.html` file in `app/routes/`. The directory tree is the route
9
+ table, so `app/routes/about.html` answers `/about`. `_layout.html` wraps every
10
+ page beside it and below.
11
+
12
+ | | |
13
+ | --- | --- |
14
+ | `npm run dev` | the dev server, with hot reload |
15
+ | `npm run check` | types, from the shapes your loaders return |
16
+ | `npm run preview` | build, then serve the build |
17
+ | `npm run build` | write `dist/` |
18
+
19
+ `dist/static` is self-contained: any static host will serve it. Everything that
20
+ reads a request is served by `npm start`.
@@ -0,0 +1,6 @@
1
+ node_modules
2
+ dist
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ .DS_Store
@@ -0,0 +1,2 @@
1
+ User-agent: *
2
+ Allow: /
@@ -0,0 +1,4 @@
1
+ <title>Not found</title>
2
+
3
+ <h1>Not found</h1>
4
+ <p>No page has that address. <a href="/">Back to the start</a>.</p>
@@ -0,0 +1,16 @@
1
+ <script server>
2
+ // A layout wraps every page in its directory and below. This one takes the
3
+ // current path so it can mark the link you are on.
4
+ export default async ({ route }) => ({ path: route.path });
5
+ </script>
6
+
7
+ <title>__NAME__</title>
8
+
9
+ <nav>
10
+ <a href="/" aria-current="${path === '/' ? 'page' : 'false'}">Home</a>
11
+ <a href="/about" aria-current="${path === '/about' ? 'page' : 'false'}">About</a>
12
+ </nav>
13
+
14
+ <!-- The page renders here. A layout with no <slot> is a warning, because
15
+ nothing inside it could appear. -->
16
+ <main><slot></slot></main>
@@ -0,0 +1,4 @@
1
+ <title>About</title>
2
+
3
+ <h1>About</h1>
4
+ <p>A second page, so the nav has somewhere to go.</p>
@@ -0,0 +1,4 @@
1
+ <title>Home</title>
2
+
3
+ <h1>__NAME__</h1>
4
+ <p>Edit <code>app/routes/index.html</code> and this changes.</p>
@@ -0,0 +1,24 @@
1
+ /* Linked by every page. It goes through Vite, so nesting and @import work, and
2
+ the build hashes and compresses it. */
3
+
4
+ :root {
5
+ --muted: #666;
6
+ color-scheme: light dark;
7
+ }
8
+
9
+ @media (prefers-color-scheme: dark) {
10
+ :root { --muted: #999; }
11
+ }
12
+
13
+ body {
14
+ margin: 0 auto;
15
+ padding: 2rem 1rem;
16
+ max-width: 42rem;
17
+ font: 16px/1.6 system-ui, sans-serif;
18
+ }
19
+
20
+ h1 { font-size: 1.6rem; margin: 0 0 1rem; }
21
+ a { color: inherit; }
22
+
23
+ nav { display: flex; gap: 1rem; margin-bottom: 2rem; }
24
+ nav a[aria-current='page'] { font-weight: 600; }
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "esnext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["esnext", "dom", "dom.iterable"],
7
+ "checkJs": false,
8
+ "noEmit": true
9
+ },
10
+ "include": ["app", "transclude.config.js"]
11
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "node --env-file-if-exists=.env node_modules/.bin/transclude-dev",
7
+ "build": "transclude-build",
8
+ "start": "node --env-file-if-exists=.env node_modules/.bin/transclude-serve",
9
+ "preview": "npm run build && npm start",
10
+ "check": "transclude-check"
11
+ },
12
+ "dependencies": {
13
+ "@transclude/core": "__TRANSCLUDE__"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.9",
17
+ "vite": "^8"
18
+ }
19
+ }
@@ -0,0 +1,27 @@
1
+ // Every path the framework needs is here, relative to `appDir` unless it says
2
+ // otherwise. `npx transclude-check` reads this too.
3
+ //
4
+ // The keys left out have defaults, and the defaults are the quiet ones: no
5
+ // proxy, no feed, no sitemap, no service worker list, no script on any page.
6
+ // See the configuration page in the docs for the whole list.
7
+
8
+ export default {
9
+ appDir: 'app',
10
+ routesDir: 'routes',
11
+ elementsDir: 'elements',
12
+ stylesheet: 'app/styles/global.css',
13
+
14
+ // Dev and production both listen here, so this app has one port. `PORT` in the
15
+ // environment wins.
16
+ port: 1960,
17
+
18
+ // `never` redirects /about/ to /about with a 301, so a page has one URL.
19
+ trailingSlash: 'never',
20
+
21
+ // Signs cookies, which is what makes one usable as a session. Read it from the
22
+ // environment: this file is yours, so where the secret lives is your decision.
23
+ cookieSecret: globalThis.process?.env?.COOKIE_SECRET ?? null,
24
+
25
+ outDir: 'dist',
26
+ typesFile: 'app/transclude-env.d.ts',
27
+ };
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vite';
2
+ import transclude from '@transclude/core';
3
+ import config from './transclude.config.js';
4
+
5
+ export default defineConfig({
6
+ appType: 'custom',
7
+ plugins: [transclude(config)],
8
+ });