create-hlou-site 0.0.0-stage → 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 EMIKETIC
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/README.md CHANGED
@@ -1,3 +1,50 @@
1
- # Temporary Holding Version
1
+ # create-hlou-site
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Start a [Hlou](https://github.com/hloudev/hlou-starter-next) site on your machine,
4
+ before its Hlou project exists.
5
+
6
+ ```bash
7
+ npm create hlou-site@latest my-site
8
+ cd my-site
9
+ pnpm install
10
+ pnpm dev # http://localhost:3000
11
+ ```
12
+
13
+ It asks for the site's name, its source locale and its other locales, then:
14
+
15
+ - copies the pinned release of
16
+ [hlou-starter-next](https://github.com/hloudev/hlou-starter-next) (MIT): the
17
+ Next.js static-export template every Hlou site is built from;
18
+ - writes an empty **site spec** in `hlou/site/`: `site.json` (locales, theme,
19
+ the site's name), `pages/home.json`, `content/<locale>.json`;
20
+ - names Hlou's Claude Code plugin marketplace in `.claude/settings.json`
21
+ (`/plugin marketplace add hloudev/hlou-claude-plugins`).
22
+
23
+ Then ask your agent for pages — the starter's `AGENTS.md` and `.claude/skills/`
24
+ teach it the spec — and bring the site to Hlou with `pnpm hlou:pack` and
25
+ **New project › Import**. The starter's README says more.
26
+
27
+ ## Options
28
+
29
+ ```text
30
+ create-hlou-site [folder] [options]
31
+
32
+ --name <name> the site's name (asked when missing)
33
+ --locale <tag> the source locale, e.g. fr (default fr)
34
+ --locales <tags> the other locales, comma-separated, e.g. en,ar
35
+ --yes, -y ask nothing: the defaults for whatever is missing
36
+ --no-plugin-hint leave .claude/settings.json as the starter has it
37
+ --from <path|url> a local starter folder or .tar.gz instead of the pinned release
38
+ --ref <commit> another commit of hloudev/hlou-starter-next
39
+ ```
40
+
41
+ `npm create hlou-site@latest my-site -- --locale fr --locales en,ar --yes`
42
+ scaffolds without a question. No dependency; Node 24 or later.
43
+
44
+ ## Maintaining it
45
+
46
+ The source lives in the Hlou monorepo (`tools/create-hlou-site/`); tests run with
47
+ `node --test` there. `starter.json` pins the starter commit the monorepo pins:
48
+ `node scripts/pin-starter.mjs` before each publish (`--check` to compare).
49
+
50
+ MIT © EMIKETIC
package/index.mjs ADDED
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * create-hlou-site — start a Hlou site before its Hlou project exists.
4
+ *
5
+ * npm create hlou-site@latest [folder] [-- options]
6
+ * pnpm create hlou-site [folder] [options]
7
+ *
8
+ * Copies the pinned release of hlou-starter-next, writes an empty site spec
9
+ * (`hlou/site/`: the name, the locales, a home page), and names Hlou's Claude
10
+ * Code plugin marketplace in `.claude/settings.json`. No dependency.
11
+ */
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ realpathSync,
18
+ rmSync,
19
+ writeFileSync,
20
+ } from 'node:fs';
21
+ import { basename, dirname, join, relative, resolve } from 'node:path';
22
+ import { stdin, stdout } from 'node:process';
23
+ import { createInterface } from 'node:readline/promises';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ import {
27
+ emptySpec,
28
+ parseLocales,
29
+ PLUGIN_MARKETPLACE,
30
+ sitePackageJson,
31
+ slugify,
32
+ SUGGESTED_LOCALES,
33
+ withPluginMarketplace,
34
+ } from './lib/spec.mjs';
35
+ import { fetchStarter } from './lib/starter.mjs';
36
+
37
+ const HELP = `Usage: create-hlou-site [folder] [options]
38
+
39
+ Options:
40
+ --name <name> the site's name (asked when missing)
41
+ --locale <tag> the source locale, e.g. fr (asked; default fr)
42
+ --locales <tags> the other locales, comma-separated, e.g. en,ar (asked; default none)
43
+ --yes, -y ask nothing: the defaults for whatever is missing
44
+ --no-plugin-hint leave .claude/settings.json as the starter has it
45
+ --from <path|url> a local starter folder or .tar.gz instead of the pinned release
46
+ --ref <commit> another commit of hloudev/hlou-starter-next
47
+ --help, -h this help
48
+ --version, -v this tool's version
49
+ `;
50
+
51
+ /** `argv` → `{ folder, name, locale, locales, yes, pluginHint, from, ref, help, version }`. */
52
+ export function parseArgs(argv) {
53
+ const out = { pluginHint: true, yes: false };
54
+ const valueOf = (index, flag) => {
55
+ const value = argv[index + 1];
56
+ if (value === undefined || value.startsWith('--')) throw new Error(`${flag} needs a value`);
57
+ return value;
58
+ };
59
+ for (let i = 0; i < argv.length; i += 1) {
60
+ const arg = argv[i];
61
+ const [flag, inline] = arg.startsWith('--') && arg.includes('=') ? arg.split(/=(.*)/s) : [arg];
62
+ const take = (key) => {
63
+ if (inline !== undefined) out[key] = inline;
64
+ else {
65
+ out[key] = valueOf(i, flag);
66
+ i += 1;
67
+ }
68
+ };
69
+ switch (flag) {
70
+ case '--name':
71
+ take('name');
72
+ break;
73
+ case '--locale':
74
+ take('locale');
75
+ break;
76
+ case '--locales':
77
+ take('locales');
78
+ break;
79
+ case '--from':
80
+ take('from');
81
+ break;
82
+ case '--ref':
83
+ take('ref');
84
+ break;
85
+ case '--yes':
86
+ case '-y':
87
+ out.yes = true;
88
+ break;
89
+ case '--no-plugin-hint':
90
+ out.pluginHint = false;
91
+ break;
92
+ case '--help':
93
+ case '-h':
94
+ out.help = true;
95
+ break;
96
+ case '--version':
97
+ case '-v':
98
+ out.version = true;
99
+ break;
100
+ default:
101
+ if (flag.startsWith('-')) throw new Error(`unknown option ${flag}`);
102
+ if (out.folder) throw new Error(`one folder only (got ${out.folder} and ${arg})`);
103
+ out.folder = arg;
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Asks what the flags left open; with `--yes` or no terminal, takes the defaults. */
110
+ async function answers(args) {
111
+ const folderDefault = args.folder ?? 'my-hlou-site';
112
+ const interactive = !args.yes && stdin.isTTY;
113
+ const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
114
+ const ask = async (question, fallback) => {
115
+ if (!rl) return fallback;
116
+ const answer = (await rl.question(`${question}${fallback ? ` (${fallback})` : ''}: `)).trim();
117
+ return answer || fallback;
118
+ };
119
+ try {
120
+ const folder = args.folder ?? (await ask('Folder', folderDefault));
121
+ const name = args.name ?? (await ask('Site name', basename(resolve(folder))));
122
+ let locales;
123
+ for (;;) {
124
+ const source = args.locale ?? (await ask('Source locale', 'fr'));
125
+ const others =
126
+ args.locales ??
127
+ (await ask(
128
+ `Other locales, comma-separated (e.g. ${SUGGESTED_LOCALES.filter(
129
+ (code) => code !== source
130
+ )
131
+ .slice(0, 3)
132
+ .join(',')})`,
133
+ ''
134
+ ));
135
+ const parsed = parseLocales(source, others);
136
+ if (parsed.locales) {
137
+ locales = parsed.locales;
138
+ break;
139
+ }
140
+ if (!rl || args.locale !== undefined || args.locales !== undefined) {
141
+ throw new Error(parsed.error);
142
+ }
143
+ stdout.write(`${parsed.error}\n`);
144
+ }
145
+ return { folder, name: name.trim() || 'Site', locales };
146
+ } finally {
147
+ rl?.close();
148
+ }
149
+ }
150
+
151
+ function toolVersion() {
152
+ const here = dirname(fileURLToPath(import.meta.url));
153
+ return JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version;
154
+ }
155
+
156
+ function writeFile(root, path, text) {
157
+ const full = join(root, path);
158
+ mkdirSync(dirname(full), { recursive: true });
159
+ writeFileSync(full, text);
160
+ }
161
+
162
+ /** The whole scaffold; returns the new site's folder. */
163
+ export async function createSite(args, log = (line) => stdout.write(`${line}\n`)) {
164
+ const { folder, name, locales } = await answers(args);
165
+ const target = resolve(folder);
166
+ if (existsSync(target) && readdirSync(target).length > 0) {
167
+ throw new Error(`${target} exists and is not empty`);
168
+ }
169
+ mkdirSync(target, { recursive: true });
170
+
171
+ try {
172
+ const fetched = await fetchStarter(target, { from: args.from, ref: args.ref });
173
+ if (!existsSync(join(target, 'package.json'))) {
174
+ throw new Error(`no package.json in the starter from ${fetched.source}`);
175
+ }
176
+ log(`Starter: ${fetched.source} (${fetched.written} files)`);
177
+
178
+ const slug = slugify(basename(target));
179
+ const starterPackage = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8'));
180
+ writeFile(
181
+ target,
182
+ 'package.json',
183
+ `${JSON.stringify(sitePackageJson(starterPackage, { slug, name }), null, 2)}\n`
184
+ );
185
+ for (const [path, text] of Object.entries(emptySpec({ name, locales }))) {
186
+ writeFile(target, path, text);
187
+ }
188
+ if (args.pluginHint) {
189
+ const settingsPath = join(target, '.claude', 'settings.json');
190
+ const existing = existsSync(settingsPath)
191
+ ? JSON.parse(readFileSync(settingsPath, 'utf8'))
192
+ : null;
193
+ writeFile(
194
+ target,
195
+ '.claude/settings.json',
196
+ `${JSON.stringify(withPluginMarketplace(existing), null, 2)}\n`
197
+ );
198
+ }
199
+ } catch (error) {
200
+ rmSync(target, { recursive: true, force: true });
201
+ throw error;
202
+ }
203
+
204
+ const where = relative(process.cwd(), target) || '.';
205
+ log('');
206
+ log(`${name} is ready in ${where} — ${locales.join(', ')} (source ${locales[0]}).`);
207
+ log('');
208
+ log(` cd ${where}`);
209
+ log(' pnpm install # pnpm 10 (corepack enable), Node 24');
210
+ log(' pnpm dev # compiles hlou/site/ and serves http://localhost:3000');
211
+ log('');
212
+ log('Then ask your agent for pages: AGENTS.md and .claude/skills/ teach it the spec.');
213
+ if (args.pluginHint) {
214
+ log('In Claude Code, Hlou’s plugins (Figma → spec):');
215
+ log(` /plugin marketplace add ${PLUGIN_MARKETPLACE.repo}`);
216
+ log(` /plugin install hlou-figma-handoff@${PLUGIN_MARKETPLACE.name}`);
217
+ }
218
+ log('When it is ready: pnpm hlou:pack, then Hlou › New project › Import.');
219
+ return target;
220
+ }
221
+
222
+ async function main() {
223
+ let args;
224
+ try {
225
+ args = parseArgs(process.argv.slice(2));
226
+ } catch (error) {
227
+ process.stderr.write(`create-hlou-site: ${error.message}\n\n${HELP}`);
228
+ return 1;
229
+ }
230
+ if (args.help) {
231
+ stdout.write(HELP);
232
+ return 0;
233
+ }
234
+ if (args.version) {
235
+ stdout.write(`${toolVersion()}\n`);
236
+ return 0;
237
+ }
238
+ const [major] = process.versions.node.split('.').map(Number);
239
+ if (major < 24) {
240
+ process.stderr.write(
241
+ `create-hlou-site: Node 24 or later is needed (this is ${process.version})\n`
242
+ );
243
+ return 1;
244
+ }
245
+ try {
246
+ await createSite(args);
247
+ return 0;
248
+ } catch (error) {
249
+ process.stderr.write(`create-hlou-site: ${error instanceof Error ? error.message : error}\n`);
250
+ return 1;
251
+ }
252
+ }
253
+
254
+ /* Run, not imported (by tests). npm calls the bin through a symlink. */
255
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
256
+ process.exitCode = await main();
257
+ }
package/lib/spec.mjs ADDED
@@ -0,0 +1,166 @@
1
+ /*
2
+ * The empty site spec a new Hlou site starts from (format 1, decision 219):
3
+ * the locales, a neutral theme, the site's name in the `site.name` slot and
4
+ * one home page with no section yet. Pure: options in, files out.
5
+ */
6
+
7
+ /** A language tag as the spec reads it (`fr`, `en-GB`, `ar-TN`). */
8
+ export const LOCALE_PATTERN = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/;
9
+ /** At most this many locales on one Hlou project, enabled or not. */
10
+ export const MAX_LOCALES = 12;
11
+ /** What Hlou's wizard offers first; any valid tag is accepted. */
12
+ export const SUGGESTED_LOCALES = [
13
+ 'fr',
14
+ 'en',
15
+ 'ar',
16
+ 'es',
17
+ 'de',
18
+ 'it',
19
+ 'pt',
20
+ 'nl',
21
+ 'tr',
22
+ 'ru',
23
+ 'zh',
24
+ 'ja',
25
+ 'fr-CA',
26
+ 'en-GB',
27
+ 'pt-BR',
28
+ ];
29
+
30
+ /** The home page's title, in the source locale when we know the word. */
31
+ const HOME_TITLE = {
32
+ fr: 'Accueil',
33
+ en: 'Home',
34
+ ar: 'الرئيسية',
35
+ es: 'Inicio',
36
+ de: 'Startseite',
37
+ it: 'Home',
38
+ pt: 'Início',
39
+ nl: 'Home',
40
+ tr: 'Ana sayfa',
41
+ };
42
+
43
+ export function homeTitle(locale) {
44
+ return HOME_TITLE[locale] ?? HOME_TITLE[locale.split('-')[0]] ?? 'Home';
45
+ }
46
+
47
+ /** `my-site` from `My Site!` — a folder and package name. */
48
+ export function slugify(text) {
49
+ const slug = String(text)
50
+ .normalize('NFKD')
51
+ .replace(/[̀-ͯ]/g, '')
52
+ .toLowerCase()
53
+ .replace(/[^a-z0-9]+/g, '-')
54
+ .replace(/^-+|-+$/g, '')
55
+ .slice(0, 60)
56
+ .replace(/-+$/g, '');
57
+ return slug || 'site';
58
+ }
59
+
60
+ /**
61
+ * `fr` and `en, ar` → the locale list, source first, or the reason it is not
62
+ * one. Duplicates are dropped; a tag is checked as the spec checks it.
63
+ */
64
+ export function parseLocales(source, others) {
65
+ const sourceCode = String(source ?? '').trim();
66
+ if (!LOCALE_PATTERN.test(sourceCode)) {
67
+ return { error: `« ${sourceCode} » is not a language tag such as fr, en-GB or ar-TN` };
68
+ }
69
+ const list = [sourceCode];
70
+ const rest = Array.isArray(others) ? others : String(others ?? '').split(/[\s,;]+/);
71
+ for (const raw of rest) {
72
+ const code = String(raw).trim();
73
+ if (!code) continue;
74
+ if (!LOCALE_PATTERN.test(code)) {
75
+ return { error: `« ${code} » is not a language tag such as fr, en-GB or ar-TN` };
76
+ }
77
+ if (!list.includes(code)) list.push(code);
78
+ }
79
+ if (list.length > MAX_LOCALES) {
80
+ return { error: `A Hlou site has at most ${MAX_LOCALES} locales (${list.length} given)` };
81
+ }
82
+ return { locales: list };
83
+ }
84
+
85
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
86
+
87
+ /**
88
+ * The spec's files, by path from the site's root: `hlou/site/site.json`,
89
+ * `hlou/site/pages/home.json` and the source locale's content file.
90
+ */
91
+ export function emptySpec({ name, locales }) {
92
+ const [source] = locales;
93
+ return {
94
+ 'hlou/site/site.json': json({
95
+ $schema: '../../renderer/spec/json-schema/site.schema.json',
96
+ format: 1,
97
+ project: { sourceLocale: source },
98
+ locales: locales.map((code) => ({ code })),
99
+ theme: {
100
+ colors: [
101
+ { key: 'accent', value: '#b0552f' },
102
+ { key: 'paper', value: '#f7f5f0' },
103
+ { key: 'ink', value: '#1c1a17' },
104
+ ],
105
+ fonts: [
106
+ { key: 'heading', source: 'google', family: 'Inter' },
107
+ { key: 'body', source: 'google', family: 'Inter' },
108
+ ],
109
+ },
110
+ /* The public site name (the renderer's `site.name`): titles, share cards. */
111
+ slots: [{ key: 'site.name', kind: 'text' }],
112
+ }),
113
+ 'hlou/site/pages/home.json': json({
114
+ $schema: '../../../renderer/spec/json-schema/page.schema.json',
115
+ path: '/',
116
+ title: homeTitle(source),
117
+ position: 0,
118
+ status: 'ready',
119
+ sections: [],
120
+ }),
121
+ [`hlou/site/content/${source}.json`]: json({
122
+ $schema: '../../../renderer/spec/json-schema/content.schema.json',
123
+ values: { 'site/site.name': { kind: 'text', text: name } },
124
+ }),
125
+ };
126
+ }
127
+
128
+ /** The Claude Code plugin marketplace the scaffold names (decision 206). */
129
+ export const PLUGIN_MARKETPLACE = {
130
+ name: 'hlou-claude-plugins',
131
+ repo: 'hloudev/hlou-claude-plugins',
132
+ };
133
+
134
+ /**
135
+ * `.claude/settings.json` with Hlou's plugin marketplace known: Claude Code
136
+ * offers it when the folder is trusted. `existing` is the starter's file, if
137
+ * any; nothing in it is dropped.
138
+ */
139
+ export function withPluginMarketplace(existing) {
140
+ const settings = existing && typeof existing === 'object' ? { ...existing } : {};
141
+ settings.extraKnownMarketplaces = {
142
+ ...(settings.extraKnownMarketplaces ?? {}),
143
+ [PLUGIN_MARKETPLACE.name]: {
144
+ source: { source: 'github', repo: PLUGIN_MARKETPLACE.repo },
145
+ },
146
+ };
147
+ return settings;
148
+ }
149
+
150
+ /** The scaffold's `package.json`: the site's own, private, no starter metadata. */
151
+ export function sitePackageJson(starter, { slug, name }) {
152
+ const {
153
+ license: _license,
154
+ repository: _repository,
155
+ homepage: _homepage,
156
+ bugs: _bugs,
157
+ ...rest
158
+ } = starter;
159
+ return {
160
+ ...rest,
161
+ name: slug,
162
+ version: '0.1.0',
163
+ private: true,
164
+ description: `${name} — a Hlou site`,
165
+ };
166
+ }
@@ -0,0 +1,201 @@
1
+ /*
2
+ * Where the starter comes from: a pinned commit of the public repository
3
+ * (`starter.json`), downloaded as GitHub's tarball and unpacked with no
4
+ * dependency — or, for tests and offline work, a local folder or `.tar.gz`
5
+ * (`--from`).
6
+ */
7
+
8
+ import {
9
+ chmodSync,
10
+ cpSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ statSync,
15
+ writeFileSync,
16
+ } from 'node:fs';
17
+ import { dirname, join, posix, resolve } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { gunzipSync } from 'node:zlib';
20
+
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+
23
+ /** `{ repository, ref }`: the starter commit this version of the tool scaffolds. */
24
+ export function pinnedStarter() {
25
+ return JSON.parse(readFileSync(join(here, '..', 'starter.json'), 'utf8'));
26
+ }
27
+
28
+ export function tarballUrl({ repository, ref }) {
29
+ return `https://codeload.github.com/${repository}/tar.gz/${ref}`;
30
+ }
31
+
32
+ /* -------------------------------------------------------------------------- */
33
+ /* Excluded */
34
+ /* -------------------------------------------------------------------------- */
35
+
36
+ /** Never copied from a starter, wherever they sit. */
37
+ const SKIP_ANYWHERE = new Set(['.git', 'node_modules', '.next', '.DS_Store', '.vercel']);
38
+ /**
39
+ * Never copied from the starter's root: a build, compiled output, the
40
+ * sample's media, and the starter's own CI (it tests the starter, not a site).
41
+ */
42
+ const SKIP_AT_ROOT = new Set(['out', 'site', 'hlou', 'public/media', '.github/workflows']);
43
+
44
+ function isSkippedFile(name) {
45
+ return (
46
+ ((name === '.env' || name.startsWith('.env.')) && name !== '.env.example') ||
47
+ name.endsWith('.tsbuildinfo') ||
48
+ name.endsWith('.hlou.zip')
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Whether a path of the starter (posix, from its root) stays out of a new
54
+ * site. `site/` and `public/media/` hold the starter's sample (Hlou's own
55
+ * fixture); a new site compiles `site/` from its spec and starts with no media.
56
+ */
57
+ export function isSkipped(path) {
58
+ const parts = path.split('/');
59
+ if (parts.some((part) => SKIP_ANYWHERE.has(part))) return true;
60
+ if (SKIP_AT_ROOT.has(parts[0]) || SKIP_AT_ROOT.has(parts.slice(0, 2).join('/'))) return true;
61
+ return isSkippedFile(parts[parts.length - 1]);
62
+ }
63
+
64
+ /* -------------------------------------------------------------------------- */
65
+ /* Tar */
66
+ /* -------------------------------------------------------------------------- */
67
+
68
+ function cString(block, start, length) {
69
+ const slice = block.subarray(start, start + length);
70
+ const end = slice.indexOf(0);
71
+ return slice.subarray(0, end < 0 ? slice.length : end).toString('utf8');
72
+ }
73
+
74
+ function octal(block, start, length) {
75
+ /* GNU base-256 for sizes over 8 GB: never in a starter. */
76
+ if (block[start] & 0x80) throw new Error('tar: base-256 sizes are not supported');
77
+ const text = cString(block, start, length).trim();
78
+ return text ? parseInt(text, 8) : 0;
79
+ }
80
+
81
+ /** `{ path: value }` of a pax extended header. */
82
+ function paxRecords(body) {
83
+ const records = {};
84
+ let offset = 0;
85
+ while (offset < body.length) {
86
+ const space = body.indexOf(0x20, offset);
87
+ if (space < 0) break;
88
+ const length = parseInt(body.subarray(offset, space).toString('utf8'), 10);
89
+ if (!length) break;
90
+ const record = body.subarray(space + 1, offset + length - 1).toString('utf8');
91
+ const equals = record.indexOf('=');
92
+ if (equals > 0) records[record.slice(0, equals)] = record.slice(equals + 1);
93
+ offset += length;
94
+ }
95
+ return records;
96
+ }
97
+
98
+ /**
99
+ * The regular files of a tar archive: `{ path, body, mode }`, in order.
100
+ * Understands ustar, pax (`x` / `g`) and GNU long names (`L`); symlinks and
101
+ * other special entries are listed in `skipped`, never written.
102
+ */
103
+ export function readTar(bytes) {
104
+ const files = [];
105
+ const skipped = [];
106
+ let offset = 0;
107
+ let nextPath = null;
108
+ while (offset + 512 <= bytes.length) {
109
+ const header = bytes.subarray(offset, offset + 512);
110
+ if (header.every((byte) => byte === 0)) break;
111
+ const size = octal(header, 124, 12);
112
+ const type = String.fromCharCode(header[156] || 0x30);
113
+ const body = bytes.subarray(offset + 512, offset + 512 + size);
114
+ offset += 512 + Math.ceil(size / 512) * 512;
115
+
116
+ if (type === 'x') {
117
+ nextPath = paxRecords(body).path ?? nextPath;
118
+ continue;
119
+ }
120
+ if (type === 'g') continue;
121
+ if (type === 'L') {
122
+ nextPath = cString(body, 0, body.length);
123
+ continue;
124
+ }
125
+ const name = cString(header, 0, 100);
126
+ const prefix = cString(header, 345, 155);
127
+ const path = nextPath ?? (prefix ? `${prefix}/${name}` : name);
128
+ nextPath = null;
129
+
130
+ if (type === '0' || type === '7') {
131
+ files.push({ path, body: Buffer.from(body), mode: octal(header, 100, 8) });
132
+ } else if (type !== '5') {
133
+ skipped.push(path);
134
+ }
135
+ }
136
+ return { files, skipped };
137
+ }
138
+
139
+ /** A path inside the archive's one top folder, or null when it escapes. */
140
+ function safeRelative(path) {
141
+ const parts = path.split('/').filter(Boolean);
142
+ parts.shift(); // GitHub's `<repo>-<ref>/`
143
+ const relative = parts.join('/');
144
+ if (!relative) return null;
145
+ const normal = posix.normalize(relative);
146
+ if (normal.startsWith('../') || normal === '..' || posix.isAbsolute(normal)) return null;
147
+ return normal;
148
+ }
149
+
150
+ /** Unpacks a `.tar.gz` of the starter into `target`, its top folder removed. */
151
+ export function extractStarter(gzipped, target) {
152
+ const { files, skipped } = readTar(gunzipSync(gzipped));
153
+ let written = 0;
154
+ for (const file of files) {
155
+ const path = safeRelative(file.path);
156
+ if (!path || isSkipped(path)) continue;
157
+ const full = join(target, path);
158
+ mkdirSync(dirname(full), { recursive: true });
159
+ writeFileSync(full, file.body);
160
+ if (file.mode & 0o111) chmodSync(full, 0o755);
161
+ written += 1;
162
+ }
163
+ return { written, skipped };
164
+ }
165
+
166
+ /** Copies a local starter folder into `target`, as the tarball would. */
167
+ export function copyStarter(source, target) {
168
+ let written = 0;
169
+ cpSync(source, target, {
170
+ recursive: true,
171
+ filter: (src) => {
172
+ const path = src.slice(source.length).split(/[\\/]/).filter(Boolean).join('/');
173
+ if (!path) return true;
174
+ if (isSkipped(path)) return false;
175
+ if (statSync(src).isFile()) written += 1;
176
+ return true;
177
+ },
178
+ });
179
+ return { written, skipped: [] };
180
+ }
181
+
182
+ /**
183
+ * Lays the starter into `target`: from `from` (a folder, a `.tar.gz`, or a
184
+ * `file://` URL of either) when given, else the pinned commit from GitHub.
185
+ */
186
+ export async function fetchStarter(target, { from, ref } = {}) {
187
+ if (from) {
188
+ const path = from.startsWith('file://') ? fileURLToPath(from) : resolve(from);
189
+ if (!existsSync(path)) throw new Error(`--from: ${path} does not exist`);
190
+ if (statSync(path).isDirectory()) return { ...copyStarter(path, target), source: path };
191
+ return { ...extractStarter(readFileSync(path), target), source: path };
192
+ }
193
+ const pinned = pinnedStarter();
194
+ const url = tarballUrl({ ...pinned, ...(ref ? { ref } : {}) });
195
+ const response = await fetch(url);
196
+ if (!response.ok) {
197
+ throw new Error(`could not download the starter (${response.status}) from ${url}`);
198
+ }
199
+ const bytes = Buffer.from(await response.arrayBuffer());
200
+ return { ...extractStarter(bytes, target), source: url };
201
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "create-hlou-site",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Start a Hlou site: the Next.js starter with an empty site spec, ready for Claude Code.",
5
+ "keywords": [
6
+ "hlou",
7
+ "create",
8
+ "starter",
9
+ "nextjs",
10
+ "static-site",
11
+ "claude-code"
12
+ ],
13
+ "type": "module",
14
+ "bin": {
15
+ "create-hlou-site": "index.mjs"
16
+ },
17
+ "files": [
18
+ "index.mjs",
19
+ "lib/",
20
+ "starter.json",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=24"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test test/*.test.mjs",
29
+ "pin": "node scripts/pin-starter.mjs"
30
+ },
31
+ "license": "MIT",
32
+ "author": "EMIKETIC",
33
+ "homepage": "https://github.com/hloudev/hlou-starter-next#readme",
34
+ "bugs": {
35
+ "url": "https://github.com/hloudev/hlou-starter-next/issues"
36
+ }
37
+ }
package/starter.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "repository": "hloudev/hlou-starter-next",
3
+ "ref": "afb0c9cac03af6add0f3326905db20aacb421582"
4
+ }