uniweb 1.0.0 → 1.0.2
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 +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolding Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared scaffolding functions used by both `create` and `add` commands.
|
|
5
|
+
* Each function scaffolds a single package from its package template.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs/promises'
|
|
9
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
10
|
+
import { join, dirname } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import yaml from 'js-yaml'
|
|
13
|
+
import Handlebars from 'handlebars'
|
|
14
|
+
import {
|
|
15
|
+
copyTemplateDirectory,
|
|
16
|
+
enumerateTemplateOutputs,
|
|
17
|
+
registerVersions
|
|
18
|
+
} from '../templates/processor.js'
|
|
19
|
+
import { getVersionsForTemplates, getCliVersion } from '../versions.js'
|
|
20
|
+
import { writeJsonPreservingStyleAsync } from './json-file.js'
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
23
|
+
const TEMPLATES_DIR = join(__dirname, '..', '..', 'templates')
|
|
24
|
+
const STARTER_DIR = join(__dirname, '..', '..', 'starter')
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Scaffold a workspace root from the workspace package template
|
|
28
|
+
*
|
|
29
|
+
* @param {string} targetDir - Target directory
|
|
30
|
+
* @param {Object} context - Template context
|
|
31
|
+
* @param {string} context.projectName - Workspace/project name
|
|
32
|
+
* @param {string[]} context.workspaceGlobs - Workspace glob patterns
|
|
33
|
+
* @param {Object} context.scripts - Root package.json scripts
|
|
34
|
+
* @param {Object} [options] - Processing options
|
|
35
|
+
*/
|
|
36
|
+
export async function scaffoldWorkspace(targetDir, context, options = {}) {
|
|
37
|
+
registerVersions(getVersionsForTemplates())
|
|
38
|
+
|
|
39
|
+
// Skip pnpm-workspace.yaml for blank workspaces (no packages yet).
|
|
40
|
+
// The `add` command creates it on demand via addWorkspaceGlob().
|
|
41
|
+
// Without this, pnpm fails with ERR_PNPM_NO_IMPORTER_MANIFEST_FOUND
|
|
42
|
+
// because an empty packages: [] makes pnpm search parent directories.
|
|
43
|
+
const skip = context.workspaceGlobs?.length ? [] : ['pnpm-workspace.yaml']
|
|
44
|
+
|
|
45
|
+
// Inject CLI version for AGENTS.md stamp
|
|
46
|
+
context = { ...context, cliVersion: getCliVersion() }
|
|
47
|
+
|
|
48
|
+
const templatePath = join(TEMPLATES_DIR, 'workspace')
|
|
49
|
+
await copyTemplateDirectory(templatePath, targetDir, context, {
|
|
50
|
+
onProgress: options.onProgress,
|
|
51
|
+
onWarning: options.onWarning,
|
|
52
|
+
skip
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Return the relative paths the workspace template would write into the
|
|
58
|
+
* project root, given the same skip rules `scaffoldWorkspace` applies.
|
|
59
|
+
* Used by the in-place create flow (`uniweb create .`) to detect conflicts
|
|
60
|
+
* before any I/O begins.
|
|
61
|
+
*
|
|
62
|
+
* Only the workspace template's outputs are enumerated. The foundation,
|
|
63
|
+
* site, and starter stages write into newly-created subdirectories
|
|
64
|
+
* (`src/`, `site/`) that don't pre-exist in a target like a fresh GitHub
|
|
65
|
+
* clone, so they can't conflict.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} [options]
|
|
68
|
+
* @param {boolean} [options.blank] - True when scaffolding a blank workspace
|
|
69
|
+
* (no packages yet) — skips `pnpm-workspace.yaml`, mirroring scaffoldWorkspace.
|
|
70
|
+
* @returns {Promise<string[]>}
|
|
71
|
+
*/
|
|
72
|
+
export async function getWorkspaceTemplateOutputs({ blank = false } = {}) {
|
|
73
|
+
const skip = blank ? ['pnpm-workspace.yaml'] : []
|
|
74
|
+
const templatePath = join(TEMPLATES_DIR, 'workspace')
|
|
75
|
+
return enumerateTemplateOutputs(templatePath, { skip })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Scaffold a foundation from the foundation package template.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} targetDir
|
|
82
|
+
* @param {Object} context - Template context
|
|
83
|
+
* @param {string} context.name - Package name — workspace plumbing (`src`, `docs-src`)
|
|
84
|
+
* @param {string} [context.registryName] - The foundation's own name, written to
|
|
85
|
+
* main.js's `name`: what it registers as (`@org/<name>`). Omitted when none can be
|
|
86
|
+
* made, and `uniweb register` asks for one
|
|
87
|
+
* @param {string} context.projectName - Workspace name
|
|
88
|
+
* @param {boolean} [context.isExtension]
|
|
89
|
+
*/
|
|
90
|
+
export async function scaffoldFoundation(targetDir, context, options = {}) {
|
|
91
|
+
registerVersions(getVersionsForTemplates())
|
|
92
|
+
|
|
93
|
+
const templatePath = join(TEMPLATES_DIR, 'foundation')
|
|
94
|
+
await copyTemplateDirectory(templatePath, targetDir, context, {
|
|
95
|
+
onProgress: options.onProgress,
|
|
96
|
+
onWarning: options.onWarning
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Scaffold a site from the site package template
|
|
102
|
+
*
|
|
103
|
+
* Two shapes, by whether a local foundation is wired:
|
|
104
|
+
* - Local foundation (the default for `create`/`add site`): pass
|
|
105
|
+
* `foundationName` + `foundationPath` (a `file:` spec) and the site's
|
|
106
|
+
* package.json gets that dependency.
|
|
107
|
+
* - Referenced foundation (no local sibling — what `uniweb clone` uses):
|
|
108
|
+
* OMIT `foundationName`/`foundationPath` and set `foundationRef` to a
|
|
109
|
+
* registry ref (`@ns/name@ver`) or URL. No `file:` dependency is written;
|
|
110
|
+
* `site.yml::foundation` carries the ref and the runtime loads it as a
|
|
111
|
+
* federated module (runtime mode). Deps still pin to the CLI version
|
|
112
|
+
* matrix via the template's `{{version}}` helper.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} targetDir - Target directory for the site
|
|
115
|
+
* @param {Object} context - Template context
|
|
116
|
+
* @param {string} context.name - Package name
|
|
117
|
+
* @param {string} context.projectName - Workspace name
|
|
118
|
+
* @param {string} [context.foundationName] - Local foundation package name (omit for a referenced foundation)
|
|
119
|
+
* @param {string} [context.foundationPath] - Relative file: path to a local foundation (omit for a referenced foundation)
|
|
120
|
+
* @param {string} [context.foundationRef] - Foundation ref written to site.yml (a local package name, a registry ref, or a URL)
|
|
121
|
+
* @param {Object} [options] - Processing options
|
|
122
|
+
*/
|
|
123
|
+
export async function scaffoldSite(targetDir, context, options = {}) {
|
|
124
|
+
registerVersions(getVersionsForTemplates())
|
|
125
|
+
|
|
126
|
+
const templatePath = join(TEMPLATES_DIR, 'site')
|
|
127
|
+
// A ref is written as a YAML scalar, not pasted as text: a scoped ref
|
|
128
|
+
// (`@acme/base@1.0.0`, `@acme/marketing`) starts with `@`, which plain YAML
|
|
129
|
+
// reserves, so the file would not parse — and every reader that swallows
|
|
130
|
+
// the parse error then reports the site as having no `$uuid`. js-yaml quotes
|
|
131
|
+
// only when needed, so `src` stays `src`. Rendered with `{{{ }}}`, because
|
|
132
|
+
// Handlebars' HTML escaping would also rewrite a URL's `&` and `=`.
|
|
133
|
+
const siteContext = context.foundationRef
|
|
134
|
+
? { ...context, foundationRefYaml: yaml.dump(context.foundationRef, { lineWidth: -1 }).trim() }
|
|
135
|
+
: context
|
|
136
|
+
await copyTemplateDirectory(templatePath, targetDir, siteContext, {
|
|
137
|
+
onProgress: options.onProgress,
|
|
138
|
+
onWarning: options.onWarning
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Apply content overlay from a content directory onto a target
|
|
144
|
+
*
|
|
145
|
+
* Content files overwrite scaffolded defaults. Structural files
|
|
146
|
+
* (package.json, vite.config.js, entry.js, index.html) are NOT overwritten.
|
|
147
|
+
*
|
|
148
|
+
* Note: a foundation's `main.js` (the user-authored declarations file) is
|
|
149
|
+
* NOT structural — templates legitimately provide their own `main.js` to
|
|
150
|
+
* override the empty scaffold default. The site's `entry.js` (formerly
|
|
151
|
+
* `main.js`) IS structural — the boilerplate `start({...})` is identical
|
|
152
|
+
* across all sites and shouldn't be overwritten.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} contentDir - Source content directory (e.g., starter/foundation/)
|
|
155
|
+
* @param {string} targetDir - Target directory to overlay onto
|
|
156
|
+
* @param {Object} context - Handlebars context for .hbs files
|
|
157
|
+
* @param {Object} [options] - Processing options
|
|
158
|
+
*/
|
|
159
|
+
export async function applyContent(
|
|
160
|
+
contentDir,
|
|
161
|
+
targetDir,
|
|
162
|
+
context,
|
|
163
|
+
options = {}
|
|
164
|
+
) {
|
|
165
|
+
if (!existsSync(contentDir)) return
|
|
166
|
+
|
|
167
|
+
registerVersions(getVersionsForTemplates())
|
|
168
|
+
|
|
169
|
+
// Structural files that content should never overwrite
|
|
170
|
+
const STRUCTURAL_FILES = new Set([
|
|
171
|
+
'package.json',
|
|
172
|
+
'vite.config.js',
|
|
173
|
+
'entry.js',
|
|
174
|
+
'index.html',
|
|
175
|
+
'.gitignore'
|
|
176
|
+
])
|
|
177
|
+
|
|
178
|
+
// Config files that should be merged, not overwritten: for each listed
|
|
179
|
+
// key, when the scaffolded version's value is used.
|
|
180
|
+
//
|
|
181
|
+
// foundation ALWAYS — the CLI resolved it for this project, and a
|
|
182
|
+
// content template cannot know it.
|
|
183
|
+
// name ONLY WHEN THE TEMPLATE SETS NONE. A template's own name is
|
|
184
|
+
// the default name of a site made from it — the same thing
|
|
185
|
+
// cloning a template in an app gives — so a literal
|
|
186
|
+
// `name: Product Launch` survives, and the project name fills
|
|
187
|
+
// in for a template with no name, or only the
|
|
188
|
+
// `{{projectName}}` placeholder.
|
|
189
|
+
const MERGE_FILES = {
|
|
190
|
+
'site.yml': [
|
|
191
|
+
['name', 'when-template-has-none'],
|
|
192
|
+
['foundation', 'always']
|
|
193
|
+
]
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await copyContentRecursive(
|
|
197
|
+
contentDir,
|
|
198
|
+
targetDir,
|
|
199
|
+
context,
|
|
200
|
+
STRUCTURAL_FILES,
|
|
201
|
+
MERGE_FILES,
|
|
202
|
+
options,
|
|
203
|
+
0
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* What a project records about where IT synced and deployed — a site's identity on
|
|
209
|
+
* each backend, its deploy targets and history, and the local caches of both. A new
|
|
210
|
+
* project made from a template owns none of it: carried over, its first push would
|
|
211
|
+
* update the template's own site. The same line `uniweb forget --all` draws for a
|
|
212
|
+
* copied project. Matched at the top of a content directory, where a site keeps them.
|
|
213
|
+
*/
|
|
214
|
+
const INHERITED_STATE = new Set(['sync.json', 'deploy.yml', '.uniweb'])
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Recursively copy content files, skipping structural files.
|
|
218
|
+
*
|
|
219
|
+
* `depth` is tracked so the project-state names (INHERITED_STATE) are
|
|
220
|
+
* skipped only at the top of the content directory.
|
|
221
|
+
*/
|
|
222
|
+
async function copyContentRecursive(
|
|
223
|
+
sourceDir,
|
|
224
|
+
targetDir,
|
|
225
|
+
context,
|
|
226
|
+
structuralFiles,
|
|
227
|
+
mergeFiles,
|
|
228
|
+
options,
|
|
229
|
+
depth = 0
|
|
230
|
+
) {
|
|
231
|
+
await fs.mkdir(targetDir, { recursive: true })
|
|
232
|
+
|
|
233
|
+
const entries = readdirSync(sourceDir, { withFileTypes: true })
|
|
234
|
+
|
|
235
|
+
for (const entry of entries) {
|
|
236
|
+
const sourcePath = join(sourceDir, entry.name)
|
|
237
|
+
|
|
238
|
+
if (entry.isDirectory()) {
|
|
239
|
+
if (depth === 0 && INHERITED_STATE.has(entry.name)) continue
|
|
240
|
+
const targetSubDir = join(targetDir, entry.name)
|
|
241
|
+
await copyContentRecursive(
|
|
242
|
+
sourcePath,
|
|
243
|
+
targetSubDir,
|
|
244
|
+
context,
|
|
245
|
+
structuralFiles,
|
|
246
|
+
mergeFiles,
|
|
247
|
+
options,
|
|
248
|
+
depth + 1
|
|
249
|
+
)
|
|
250
|
+
} else {
|
|
251
|
+
// Determine the output filename (strip .hbs extension)
|
|
252
|
+
const outputName = entry.name.endsWith('.hbs')
|
|
253
|
+
? entry.name.slice(0, -4)
|
|
254
|
+
: entry.name
|
|
255
|
+
|
|
256
|
+
// Skip structural files
|
|
257
|
+
if (structuralFiles.has(outputName)) continue
|
|
258
|
+
if (depth === 0 && INHERITED_STATE.has(outputName)) continue
|
|
259
|
+
|
|
260
|
+
const targetPath = join(targetDir, outputName)
|
|
261
|
+
|
|
262
|
+
// Get new content (process .hbs or read as-is)
|
|
263
|
+
let newContent
|
|
264
|
+
if (entry.name.endsWith('.hbs')) {
|
|
265
|
+
const Handlebars = (await import('handlebars')).default
|
|
266
|
+
const raw = await fs.readFile(sourcePath, 'utf-8')
|
|
267
|
+
const template = Handlebars.compile(raw)
|
|
268
|
+
newContent = template(context)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Merge config files instead of overwriting.
|
|
272
|
+
//
|
|
273
|
+
// The "merge" here is narrow: take the content template's output
|
|
274
|
+
// as the source of truth (so comments, formatting, and the full
|
|
275
|
+
// educational structure of the content template survive) and
|
|
276
|
+
// override only the specific top-level keys listed in
|
|
277
|
+
// preserveKeys with the values from the already-scaffolded base
|
|
278
|
+
// file (so the resolved foundation ref is never replaced by whatever
|
|
279
|
+
// the content template hardcoded, and a project name fills in for a
|
|
280
|
+
// template that names nothing).
|
|
281
|
+
//
|
|
282
|
+
// Earlier versions of this code parsed both files through
|
|
283
|
+
// js-yaml, merged the objects, and re-emitted the result via
|
|
284
|
+
// yaml.dump() — which stripped every comment and blank line on
|
|
285
|
+
// the way through. For templates like `cv/site/site.yml.hbs`
|
|
286
|
+
// whose comments are the template's educational payload, that
|
|
287
|
+
// was devastating. The line-level override below preserves the
|
|
288
|
+
// content template's text verbatim except for the preserved keys.
|
|
289
|
+
const preserveKeys = mergeFiles[outputName]
|
|
290
|
+
if (preserveKeys && existsSync(targetPath)) {
|
|
291
|
+
const existingContent = await fs.readFile(targetPath, 'utf-8')
|
|
292
|
+
const existing = yaml.load(existingContent) || {}
|
|
293
|
+
let merged = newContent ?? (await fs.readFile(sourcePath, 'utf-8'))
|
|
294
|
+
|
|
295
|
+
for (const [key, when] of preserveKeys) {
|
|
296
|
+
if (existing[key] === undefined) continue
|
|
297
|
+
const baseLine = matchTopLevelLine(existingContent, key)
|
|
298
|
+
if (!baseLine) continue
|
|
299
|
+
// If the new content carries the key, replace its line with
|
|
300
|
+
// the scaffolded value — unless the key only fills in, and the
|
|
301
|
+
// template set a value of its own. Otherwise insert the line —
|
|
302
|
+
// older content templates (notably `docs/site/site.yml.hbs`) omit
|
|
303
|
+
// `foundation:` entirely, and dropping it leaves the site without a
|
|
304
|
+
// foundation ref so the entry's `import '#foundation/styles'` fails
|
|
305
|
+
// at build time.
|
|
306
|
+
if (matchTopLevelLine(merged, key)) {
|
|
307
|
+
if (when === 'when-template-has-none' && hasOwnValue(merged, key)) {
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
merged = replaceTopLevelLine(merged, key, baseLine)
|
|
311
|
+
} else {
|
|
312
|
+
merged = insertTopLevelLine(merged, baseLine)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await fs.writeFile(targetPath, merged)
|
|
317
|
+
} else if (newContent !== undefined) {
|
|
318
|
+
await fs.writeFile(targetPath, newContent)
|
|
319
|
+
} else {
|
|
320
|
+
// Copy as-is
|
|
321
|
+
await fs.copyFile(sourcePath, targetPath)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (options.onProgress) {
|
|
325
|
+
options.onProgress(`Creating ${outputName}`)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Apply the built-in starter content
|
|
333
|
+
*
|
|
334
|
+
* The starter ships its foundation content under `cli/starter/foundation/`
|
|
335
|
+
* (a description of the *kind* of content), and is applied into whatever
|
|
336
|
+
* folder the workspace uses for the foundation package — which is `src/`
|
|
337
|
+
* in the current single-foundation layout, not `foundation/`. Callers
|
|
338
|
+
* can override via options when scaffolding multi-foundation workspaces.
|
|
339
|
+
*
|
|
340
|
+
* @param {string} projectDir - Root project directory
|
|
341
|
+
* @param {Object} context - Template context
|
|
342
|
+
* @param {Object} [options] - Processing options
|
|
343
|
+
* @param {string} [options.foundationDir] - Foundation directory name (default: 'src')
|
|
344
|
+
* @param {string} [options.siteDir] - Site directory name (default: 'site')
|
|
345
|
+
*/
|
|
346
|
+
export async function applyStarter(projectDir, context, options = {}) {
|
|
347
|
+
const foundationDir = options.foundationDir || 'src'
|
|
348
|
+
const siteDir = options.siteDir || 'site'
|
|
349
|
+
|
|
350
|
+
// Apply foundation starter content
|
|
351
|
+
const foundationContentDir = join(STARTER_DIR, 'foundation')
|
|
352
|
+
const foundationTargetDir = join(projectDir, foundationDir)
|
|
353
|
+
await applyContent(
|
|
354
|
+
foundationContentDir,
|
|
355
|
+
foundationTargetDir,
|
|
356
|
+
context,
|
|
357
|
+
options
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
// Apply site starter content
|
|
361
|
+
const siteContentDir = join(STARTER_DIR, 'site')
|
|
362
|
+
const siteTargetDir = join(projectDir, siteDir)
|
|
363
|
+
await applyContent(siteContentDir, siteTargetDir, context, options)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Escape a string for safe inclusion in a RegExp literal. Used by the
|
|
368
|
+
* site.yml line-level merge path.
|
|
369
|
+
*/
|
|
370
|
+
function escapeRegex(s) {
|
|
371
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Whether a content template sets `key` to a value of its own: a non-empty
|
|
376
|
+
* string that is not an unrendered `{{placeholder}}` (a plain `site.yml`, not
|
|
377
|
+
* `.hbs`, is copied without rendering). Content that does not parse counts as
|
|
378
|
+
* setting nothing, so the scaffolded value fills in, as it always did.
|
|
379
|
+
*/
|
|
380
|
+
function hasOwnValue(content, key) {
|
|
381
|
+
let value
|
|
382
|
+
try {
|
|
383
|
+
value = (yaml.load(content) || {})[key]
|
|
384
|
+
} catch {
|
|
385
|
+
return false
|
|
386
|
+
}
|
|
387
|
+
return typeof value === 'string' && value.trim() !== '' && !value.includes('{{')
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Find the verbatim text of a single-line top-level YAML entry like
|
|
392
|
+
* `name: foo bar` or `foundation: my-foundation`. Returns the matched
|
|
393
|
+
* line (including any inline comment) or null if the key isn't present.
|
|
394
|
+
* Multi-line values (block scalars, nested maps) are deliberately
|
|
395
|
+
* unsupported — the merge path only uses this for simple scalar keys.
|
|
396
|
+
*/
|
|
397
|
+
function matchTopLevelLine(content, key) {
|
|
398
|
+
const match = content.match(new RegExp(`^${escapeRegex(key)}:.*$`, 'm'))
|
|
399
|
+
return match ? match[0] : null
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Replace the verbatim text of a single-line top-level YAML entry.
|
|
404
|
+
* Returns the content unchanged if the key isn't present. See
|
|
405
|
+
* matchTopLevelLine for scope.
|
|
406
|
+
*/
|
|
407
|
+
function replaceTopLevelLine(content, key, replacement) {
|
|
408
|
+
return content.replace(
|
|
409
|
+
new RegExp(`^${escapeRegex(key)}:.*$`, 'm'),
|
|
410
|
+
() => replacement
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Insert a top-level YAML line into a content string.
|
|
416
|
+
*
|
|
417
|
+
* Used by the merge path when the content template omits a key that
|
|
418
|
+
* the scaffolded base file declared (e.g. an older `site.yml.hbs` with
|
|
419
|
+
* no `foundation:` line). Inserts immediately after the `name:` line
|
|
420
|
+
* if one exists — that's the conventional position for site
|
|
421
|
+
* configuration — and otherwise prepends to the file. The original
|
|
422
|
+
* trailing newline (or lack thereof) of the file is preserved.
|
|
423
|
+
*/
|
|
424
|
+
function insertTopLevelLine(content, line) {
|
|
425
|
+
const nameMatch = content.match(/^name:.*$/m)
|
|
426
|
+
if (nameMatch) {
|
|
427
|
+
const idx = nameMatch.index + nameMatch[0].length
|
|
428
|
+
return content.slice(0, idx) + '\n' + line + content.slice(idx)
|
|
429
|
+
}
|
|
430
|
+
return line + '\n' + content
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Resolve a dependency version string from a template.json entry.
|
|
435
|
+
*
|
|
436
|
+
* Template authors can either hardcode a concrete spec (`"^0.2.1"`) or
|
|
437
|
+
* use the same `{{version}}` Handlebars helper that `package.json.hbs`
|
|
438
|
+
* uses (`"{{version \"@uniweb/press\"}}"`). The helper is populated
|
|
439
|
+
* earlier in the scaffold flow via `registerVersions()`, so by the time
|
|
440
|
+
* this runs `versionData` already holds the current on-disk versions
|
|
441
|
+
* of every `@uniweb/*` package.
|
|
442
|
+
*
|
|
443
|
+
* Plain strings without `{{…}}` pass through untouched. Strings that
|
|
444
|
+
* fail to compile (rare — e.g. malformed mustache) fall back to the
|
|
445
|
+
* original literal so a bad template.json doesn't break scaffolding.
|
|
446
|
+
*/
|
|
447
|
+
function resolveDependencyVersion(rawValue) {
|
|
448
|
+
if (typeof rawValue !== 'string' || !rawValue.includes('{{')) {
|
|
449
|
+
return rawValue
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
return Handlebars.compile(rawValue)({})
|
|
453
|
+
} catch {
|
|
454
|
+
return rawValue
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Merge additional dependencies from a content template into a scaffolded package.json
|
|
460
|
+
*
|
|
461
|
+
* Reads the package.json at the given path, adds any deps not already present
|
|
462
|
+
* (in either dependencies or devDependencies), and writes it back.
|
|
463
|
+
*
|
|
464
|
+
* Each version string in `deps` is first processed through the shared
|
|
465
|
+
* Handlebars pipeline so template.json entries can reference live
|
|
466
|
+
* workspace versions via `{{version "@uniweb/press"}}` instead of
|
|
467
|
+
* hardcoding a spec that goes stale on the next publish.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} packageJsonPath - Absolute path to package.json
|
|
470
|
+
* @param {Object} deps - Dependencies to merge (name → version spec)
|
|
471
|
+
*/
|
|
472
|
+
export async function mergeTemplateDependencies(packageJsonPath, deps) {
|
|
473
|
+
if (!deps || Object.keys(deps).length === 0) return
|
|
474
|
+
const src = await fs.readFile(packageJsonPath, 'utf-8')
|
|
475
|
+
const pkg = JSON.parse(src)
|
|
476
|
+
if (!pkg.dependencies) pkg.dependencies = {}
|
|
477
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
478
|
+
if (!pkg.dependencies[name] && !pkg.devDependencies?.[name]) {
|
|
479
|
+
pkg.dependencies[name] = resolveDependencyVersion(version)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
await writeJsonPreservingStyleAsync(packageJsonPath, pkg, src)
|
|
483
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report queries publishing without a data schema.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ This is a PRODUCT decision the author is making, usually without knowing:
|
|
5
|
+
* entities or static files. It is reported at warn level for that reason — the
|
|
6
|
+
* producer deliberately emits a structured entry rather than a prose warning so
|
|
7
|
+
* this can say what the trade actually is.
|
|
8
|
+
*
|
|
9
|
+
* The old line said `"… — not synced"`, dim, among everything else. The data IS
|
|
10
|
+
* delivered (as static files), so that read as "my data did not upload" to
|
|
11
|
+
* anyone who did not skim past it. Neither reading tells an author what they
|
|
12
|
+
* gave up or how to get it back.
|
|
13
|
+
*
|
|
14
|
+
* @param {Array<{name: string, model?: string}>} schemaless
|
|
15
|
+
* @param {{ warn: (m: string) => void, dim: (m: string) => void }} out
|
|
16
|
+
*/
|
|
17
|
+
export function reportSchemalessQueries(schemaless, out) {
|
|
18
|
+
if (!schemaless?.length) return
|
|
19
|
+
const names = schemaless.map((c) => c.name)
|
|
20
|
+
const label = names.length === 1 ? 'query' : 'queries'
|
|
21
|
+
out.warn(
|
|
22
|
+
`${names.length} ${label} shipping as STATIC FILES, not entities: ${names.join(', ')}`
|
|
23
|
+
)
|
|
24
|
+
out.dim('No queries, no editor, no detail projection — and a republish to change the data.')
|
|
25
|
+
out.dim(
|
|
26
|
+
`Declare a data schema to get entities. Each resolves its schema by subfolder name (${schemaless
|
|
27
|
+
.map((c) => `${c.name} → ${c.model || c.name}`)
|
|
28
|
+
.join(', ')}), or set \`schema:\` on the query.`
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SemVer 2.0.0 precedence — how a registry orders a foundation's versions.
|
|
3
|
+
*
|
|
4
|
+
* A pre-release sorts below its release (`1.0.0-rc.1` < `1.0.0`), and build
|
|
5
|
+
* metadata carries no order (`1.2.0+b` equals `1.2.0`).
|
|
6
|
+
*
|
|
7
|
+
* ⚖️ Not `compareSemver` in `dep-survey.js`: that one reads dependency specs
|
|
8
|
+
* (`^1.2.3`) for `uniweb update` and compares major.minor.patch only. This one
|
|
9
|
+
* is strict — anything that is not a SemVer version is `null`, never a guess.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// The regex published with the SemVer 2.0.0 specification (semver.org, §FAQ).
|
|
13
|
+
const SEMVER =
|
|
14
|
+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {unknown} version
|
|
18
|
+
* @returns {{ major: number, minor: number, patch: number, pre: string[] }|null}
|
|
19
|
+
*/
|
|
20
|
+
export function parseSemver(version) {
|
|
21
|
+
if (typeof version !== 'string') return null
|
|
22
|
+
const m = SEMVER.exec(version)
|
|
23
|
+
if (!m) return null
|
|
24
|
+
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The next version above `version` on its own line — what `--bump` releases under
|
|
29
|
+
* when the registry already holds the local version with other code.
|
|
30
|
+
*
|
|
31
|
+
* A release gets its next patch (`1.4.2` → `1.4.3`); a pre-release its next
|
|
32
|
+
* pre-release (`2.0.0-beta.3` → `2.0.0-beta.4`, `1.0.0-rc` → `1.0.0-rc.0`), so a bump
|
|
33
|
+
* nobody chose never promotes a beta line to a release. Build metadata is dropped: it
|
|
34
|
+
* carries no order, so it could not make a version newer.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} version
|
|
37
|
+
* @returns {string|null} null when `version` is not SemVer
|
|
38
|
+
*/
|
|
39
|
+
export function nextVersionAbove(version) {
|
|
40
|
+
const v = parseSemver(version)
|
|
41
|
+
if (!v) return null
|
|
42
|
+
if (!v.pre.length) return `${v.major}.${v.minor}.${v.patch + 1}`
|
|
43
|
+
const pre = [...v.pre]
|
|
44
|
+
const last = pre.length - 1
|
|
45
|
+
if (/^\d+$/.test(pre[last])) pre[last] = String(BigInt(pre[last]) + 1n)
|
|
46
|
+
else pre.push('0')
|
|
47
|
+
return `${v.major}.${v.minor}.${v.patch}-${pre.join('.')}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {string} a
|
|
52
|
+
* @param {string} b
|
|
53
|
+
* @returns {-1|0|1|null} how `a` sorts against `b`; null when either is not SemVer
|
|
54
|
+
*/
|
|
55
|
+
export function compareSemverPrecedence(a, b) {
|
|
56
|
+
const x = parseSemver(a)
|
|
57
|
+
const y = parseSemver(b)
|
|
58
|
+
if (!x || !y) return null
|
|
59
|
+
for (const part of ['major', 'minor', 'patch']) {
|
|
60
|
+
if (x[part] !== y[part]) return x[part] > y[part] ? 1 : -1
|
|
61
|
+
}
|
|
62
|
+
// A version with a pre-release sorts below the same version without one.
|
|
63
|
+
if (!x.pre.length || !y.pre.length) {
|
|
64
|
+
return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1
|
|
65
|
+
}
|
|
66
|
+
for (let i = 0; i < Math.max(x.pre.length, y.pre.length); i++) {
|
|
67
|
+
const p = x.pre[i]
|
|
68
|
+
const q = y.pre[i]
|
|
69
|
+
if (p === undefined) return -1
|
|
70
|
+
if (q === undefined) return 1
|
|
71
|
+
if (p === q) continue
|
|
72
|
+
const pNum = /^\d+$/.test(p)
|
|
73
|
+
const qNum = /^\d+$/.test(q)
|
|
74
|
+
// Numeric identifiers compare numerically and sort below alphanumeric ones.
|
|
75
|
+
if (pNum && qNum) return BigInt(p) > BigInt(q) ? 1 : -1
|
|
76
|
+
if (pNum !== qNum) return pNum ? -1 : 1
|
|
77
|
+
return p > q ? 1 : -1
|
|
78
|
+
}
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `~/.uniweb/registry-auth.json` — the ONE reader of its shape.
|
|
3
|
+
*
|
|
4
|
+
* { version: 2, current: "<origin>", sessions: { "<origin>": { token, … } } }
|
|
5
|
+
*
|
|
6
|
+
* ⭐ ONE session since 2026-09-21 — a login replaces the file, a logout deletes it
|
|
7
|
+
* (registry-auth.js). The map shape stays so this reader also understands a file from
|
|
8
|
+
* 2026-09-20→21, when the store kept one session PER backend.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ **There were two readers until 2026-09-21, and the copy is how "the backend you are
|
|
11
|
+
* logged in to" stopped meaning anything.** `registry-auth.js` writes the file and read
|
|
12
|
+
* it; the origin ladder in `config.js` kept a sync copy that read a top-level `origin`.
|
|
13
|
+
* When the file became one session per backend (`fb4907e`), the writer moved to the shape
|
|
14
|
+
* above and the copy kept reading `origin` — a field the new shape does not have — so
|
|
15
|
+
* every command that falls back to the logged-in backend quietly fell through to the
|
|
16
|
+
* default one. Both now read through here, so the shape has one reader to change.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ Dependency-free, by requirement: `config.js` imports this, and `registry-auth.js`
|
|
19
|
+
* imports `config.js`, so this module can import neither.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
23
|
+
import { homedir } from 'node:os'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
|
|
26
|
+
export const SESSION_FILE_VERSION = 2
|
|
27
|
+
|
|
28
|
+
export function sessionFilePath() {
|
|
29
|
+
return join(homedir(), '.uniweb', 'registry-auth.json')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const originOf = (value) => {
|
|
33
|
+
try {
|
|
34
|
+
// http(s) only — `localhost:8080` parses as a scheme with the origin "null".
|
|
35
|
+
const u = new URL(value)
|
|
36
|
+
return u.protocol === 'http:' || u.protocol === 'https:' ? u.origin : null
|
|
37
|
+
} catch {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Any parsed value → `{ version: 2, current, sessions }`. Never throws.
|
|
44
|
+
*
|
|
45
|
+
* A v1 flat record is ONE session, keyed by its own `origin` stamp — or by
|
|
46
|
+
* `defaultOrigin` when it carries none — and that session is current: it was the only
|
|
47
|
+
* login there was.
|
|
48
|
+
*
|
|
49
|
+
* @param {*} raw - the parsed file, or anything
|
|
50
|
+
* @param {string} defaultOrigin
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeSessionFile(raw, defaultOrigin) {
|
|
53
|
+
const empty = { version: SESSION_FILE_VERSION, current: null, sessions: {} }
|
|
54
|
+
if (!raw || typeof raw !== 'object') return empty
|
|
55
|
+
if (raw.sessions && typeof raw.sessions === 'object' && !Array.isArray(raw.sessions)) {
|
|
56
|
+
return { version: SESSION_FILE_VERSION, current: originOf(raw.current), sessions: raw.sessions }
|
|
57
|
+
}
|
|
58
|
+
if (typeof raw.token === 'string') {
|
|
59
|
+
const { origin, ...rest } = raw
|
|
60
|
+
const key = originOf(origin) || defaultOrigin
|
|
61
|
+
return { version: SESSION_FILE_VERSION, current: key, sessions: { [key]: rest } }
|
|
62
|
+
}
|
|
63
|
+
return empty
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The file, normalized; synchronous, for the origin ladder. Never throws. */
|
|
67
|
+
export function readSessionFileSync(defaultOrigin) {
|
|
68
|
+
const p = sessionFilePath()
|
|
69
|
+
if (!existsSync(p)) return normalizeSessionFile(null, defaultOrigin)
|
|
70
|
+
try {
|
|
71
|
+
return normalizeSessionFile(JSON.parse(readFileSync(p, 'utf8')), defaultOrigin)
|
|
72
|
+
} catch {
|
|
73
|
+
return normalizeSessionFile(null, defaultOrigin)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* **The backend the user is logged in to** — the one session's backend.
|
|
79
|
+
*
|
|
80
|
+
* A file written since sessions became single holds one session, marked `current`. The
|
|
81
|
+
* other branches read what came before: a per-backend file names its last login
|
|
82
|
+
* `current`; one with a single unmarked session answers with it; several and none
|
|
83
|
+
* marked is no answer, and saying so beats guessing. An expired session still answers —
|
|
84
|
+
* the user logged in there, and the next request asks them to log in there again.
|
|
85
|
+
*
|
|
86
|
+
* @param {{ current?: string|null, sessions?: object }} file - normalized
|
|
87
|
+
* @returns {string|null}
|
|
88
|
+
*/
|
|
89
|
+
export function loggedInOriginOf(file) {
|
|
90
|
+
const sessions = file?.sessions || {}
|
|
91
|
+
const has = (o) => !!(o && sessions[o] && typeof sessions[o].token === 'string')
|
|
92
|
+
if (has(file?.current)) return file.current
|
|
93
|
+
const all = Object.keys(sessions).filter(has)
|
|
94
|
+
return all.length === 1 ? all[0] : null
|
|
95
|
+
}
|