@micazoyolli/foundation 0.3.2 → 0.4.1
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/README.md
CHANGED
|
@@ -19,6 +19,7 @@ La documentación principal vive en [foundation.nadia.dev](https://foundation.na
|
|
|
19
19
|
- Helpers DOM para metadata client-side.
|
|
20
20
|
- Primitivas de accesibilidad para focus, Escape, scroll lock y media protegida.
|
|
21
21
|
- Helpers SEO/build para canonical, sitemap, HTML estático y escaping.
|
|
22
|
+
- CLI de build para publicar `dist` en GitHub Pages sin crear commits en la rama fuente.
|
|
22
23
|
- Sin dependencias runtime pesadas y sin React.
|
|
23
24
|
|
|
24
25
|
## Instalación
|
|
@@ -72,6 +73,14 @@ yarn test
|
|
|
72
73
|
yarn docs:build
|
|
73
74
|
```
|
|
74
75
|
|
|
76
|
+
## CLI
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
micazoyolli-gh-pages-deploy
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
El CLI publica `dist` en `origin/gh-pages` desde un worktree temporal. Es tooling de build/deploy, no un export runtime.
|
|
83
|
+
|
|
75
84
|
## Autoría
|
|
76
85
|
|
|
77
86
|
Una creación de [`<micazoyolli />`](https://nadia.dev)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { deployGithubPages, GithubPagesDeployError } from '../git/githubPagesDeploy.js';
|
|
3
|
+
const main = async () => {
|
|
4
|
+
console.log('Deploying dist to origin/gh-pages...');
|
|
5
|
+
const result = await deployGithubPages();
|
|
6
|
+
if (!result.pushed) {
|
|
7
|
+
console.log(result.reason ?? 'No deployment commit was necessary.');
|
|
8
|
+
console.log('Repository left unchanged.');
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
console.log(`${result.initialDeployment ? 'Created' : 'Published'} ${result.commit} to ${result.remote}/${result.deploymentBranch}.`);
|
|
12
|
+
console.log('Repository left clean on main.');
|
|
13
|
+
};
|
|
14
|
+
main().catch((error) => {
|
|
15
|
+
if (error instanceof GithubPagesDeployError) {
|
|
16
|
+
console.error(`Deployment failed: ${error.message}`);
|
|
17
|
+
}
|
|
18
|
+
else if (error instanceof Error) {
|
|
19
|
+
console.error(`Deployment failed: ${error.message}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.error('Deployment failed with an unknown error.');
|
|
23
|
+
}
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type GithubPagesDeployOptions = {
|
|
2
|
+
buildDir?: string;
|
|
3
|
+
deploymentBranch?: string;
|
|
4
|
+
message?: string;
|
|
5
|
+
remote?: string;
|
|
6
|
+
sourceBranch?: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
};
|
|
9
|
+
export type GithubPagesDeployResult = {
|
|
10
|
+
commit?: string;
|
|
11
|
+
deploymentBranch: string;
|
|
12
|
+
initialDeployment: boolean;
|
|
13
|
+
pushed: boolean;
|
|
14
|
+
reason?: string;
|
|
15
|
+
remote: string;
|
|
16
|
+
repositoryRoot: string;
|
|
17
|
+
};
|
|
18
|
+
export declare class GithubPagesDeployError extends Error {
|
|
19
|
+
constructor(message: string);
|
|
20
|
+
}
|
|
21
|
+
export declare const deployGithubPages: (options?: GithubPagesDeployOptions) => Promise<GithubPagesDeployResult>;
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { cp, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
const DEFAULT_BUILD_DIR = 'dist';
|
|
6
|
+
const DEFAULT_DEPLOYMENT_BRANCH = 'gh-pages';
|
|
7
|
+
const DEFAULT_MESSAGE = 'build: dist actualizado correctamente';
|
|
8
|
+
const DEFAULT_REMOTE = 'origin';
|
|
9
|
+
const DEFAULT_SOURCE_BRANCH = 'main';
|
|
10
|
+
export class GithubPagesDeployError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'GithubPagesDeployError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const resolveOptions = (options = {}) => ({
|
|
17
|
+
buildDir: options.buildDir ?? DEFAULT_BUILD_DIR,
|
|
18
|
+
cwd: options.cwd ?? process.cwd(),
|
|
19
|
+
deploymentBranch: options.deploymentBranch ?? DEFAULT_DEPLOYMENT_BRANCH,
|
|
20
|
+
message: options.message ?? DEFAULT_MESSAGE,
|
|
21
|
+
remote: options.remote ?? DEFAULT_REMOTE,
|
|
22
|
+
sourceBranch: options.sourceBranch ?? DEFAULT_SOURCE_BRANCH,
|
|
23
|
+
});
|
|
24
|
+
const assertSafeGitRefPart = (value, label) => {
|
|
25
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value) || value.includes('..') || value.endsWith('/')) {
|
|
26
|
+
throw new GithubPagesDeployError(`Invalid ${label}: "${value}".`);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const runGit = (args, cwd) => {
|
|
30
|
+
const result = spawnSync('git', args, {
|
|
31
|
+
cwd,
|
|
32
|
+
encoding: 'utf8',
|
|
33
|
+
env: {
|
|
34
|
+
...process.env,
|
|
35
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (result.error) {
|
|
39
|
+
throw new GithubPagesDeployError(`Failed to run git ${args.join(' ')}: ${result.error.message}`);
|
|
40
|
+
}
|
|
41
|
+
if (result.status !== 0) {
|
|
42
|
+
const details = (result.stderr || result.stdout || '').trim();
|
|
43
|
+
throw new GithubPagesDeployError(`Git command failed: git ${args.join(' ')}${details ? `\n${details}` : ''}`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
stderr: result.stderr.trim(),
|
|
47
|
+
stdout: result.stdout.trim(),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
const getRemoteTrackingRef = (remote, branch) => `refs/remotes/${remote}/${branch}`;
|
|
51
|
+
const fetchRemoteBranch = (repositoryRoot, remote, branch) => {
|
|
52
|
+
runGit(['fetch', remote, `+${branch}:${getRemoteTrackingRef(remote, branch)}`], repositoryRoot);
|
|
53
|
+
};
|
|
54
|
+
const remoteBranchExists = (repositoryRoot, remote, branch) => {
|
|
55
|
+
const result = spawnSync('git', ['ls-remote', '--exit-code', '--heads', remote, branch], {
|
|
56
|
+
cwd: repositoryRoot,
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
env: {
|
|
59
|
+
...process.env,
|
|
60
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
if (result.error) {
|
|
64
|
+
throw new GithubPagesDeployError(`Failed to check ${remote}/${branch}: ${result.error.message}`);
|
|
65
|
+
}
|
|
66
|
+
if (result.status === 0) {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (result.status === 2) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const details = (result.stderr || result.stdout || '').trim();
|
|
73
|
+
throw new GithubPagesDeployError(`Git command failed: git ls-remote --exit-code --heads ${remote} ${branch}${details ? `\n${details}` : ''}`);
|
|
74
|
+
};
|
|
75
|
+
const getRepositoryRoot = (cwd) => {
|
|
76
|
+
try {
|
|
77
|
+
return runGit(['rev-parse', '--show-toplevel'], cwd).stdout;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new GithubPagesDeployError('Current directory must be inside a Git repository.');
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const toGitPath = (path) => path.split(sep).join('/');
|
|
84
|
+
const getBuildDirectoryPathspec = (repositoryRoot, buildDirectory) => {
|
|
85
|
+
const relativeBuildDirectory = relative(repositoryRoot, buildDirectory);
|
|
86
|
+
if (!relativeBuildDirectory || relativeBuildDirectory === '..' || relativeBuildDirectory.startsWith(`..${sep}`) || isAbsolute(relativeBuildDirectory)) {
|
|
87
|
+
throw new GithubPagesDeployError(`Build directory must be inside the repository: ${buildDirectory}`);
|
|
88
|
+
}
|
|
89
|
+
return toGitPath(relativeBuildDirectory);
|
|
90
|
+
};
|
|
91
|
+
const assertBuildDirectoryIsUntracked = (repositoryRoot, buildDirectoryPathspec, sourceBranch) => {
|
|
92
|
+
const trackedFiles = runGit(['ls-files', '--', buildDirectoryPathspec], repositoryRoot).stdout;
|
|
93
|
+
if (trackedFiles) {
|
|
94
|
+
throw new GithubPagesDeployError([
|
|
95
|
+
`Build directory "${buildDirectoryPathspec}" must not be tracked by Git.`,
|
|
96
|
+
`Remove it from ${sourceBranch} with: git rm -r --cached ${buildDirectoryPathspec}`,
|
|
97
|
+
`Keep "${buildDirectoryPathspec}/" ignored in .gitignore before deploying.`,
|
|
98
|
+
].join('\n'));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const assertCleanWorkingTree = (repositoryRoot) => {
|
|
102
|
+
const status = runGit(['status', '--porcelain'], repositoryRoot).stdout;
|
|
103
|
+
if (status) {
|
|
104
|
+
throw new GithubPagesDeployError(`Working tree must be clean before deploying.\n${status}`);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
const assertSyncedBranch = (repositoryRoot, sourceBranch, remote) => {
|
|
108
|
+
const currentBranch = runGit(['branch', '--show-current'], repositoryRoot).stdout;
|
|
109
|
+
if (currentBranch !== sourceBranch) {
|
|
110
|
+
throw new GithubPagesDeployError(`Current branch must be "${sourceBranch}". Found "${currentBranch || 'detached HEAD'}".`);
|
|
111
|
+
}
|
|
112
|
+
fetchRemoteBranch(repositoryRoot, remote, sourceBranch);
|
|
113
|
+
const localHead = runGit(['rev-parse', sourceBranch], repositoryRoot).stdout;
|
|
114
|
+
const remoteHead = runGit(['rev-parse', `${remote}/${sourceBranch}`], repositoryRoot).stdout;
|
|
115
|
+
if (localHead !== remoteHead) {
|
|
116
|
+
throw new GithubPagesDeployError(`${sourceBranch} must be synchronized with ${remote}/${sourceBranch} before deploying.`);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const assertBuildDirectory = async (buildDirectory) => {
|
|
120
|
+
let buildStat;
|
|
121
|
+
try {
|
|
122
|
+
buildStat = await stat(buildDirectory);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new GithubPagesDeployError(`Build directory does not exist: ${buildDirectory}`);
|
|
126
|
+
}
|
|
127
|
+
if (!buildStat.isDirectory()) {
|
|
128
|
+
throw new GithubPagesDeployError(`Build path must be a directory: ${buildDirectory}`);
|
|
129
|
+
}
|
|
130
|
+
const entries = await readdir(buildDirectory);
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
throw new GithubPagesDeployError(`Build directory is empty: ${buildDirectory}`);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const clearWorktree = async (worktreePath) => {
|
|
136
|
+
const entries = await readdir(worktreePath);
|
|
137
|
+
await Promise.all(entries
|
|
138
|
+
.filter((entry) => entry !== '.git')
|
|
139
|
+
.map((entry) => rm(join(worktreePath, entry), {
|
|
140
|
+
force: true,
|
|
141
|
+
recursive: true,
|
|
142
|
+
})));
|
|
143
|
+
};
|
|
144
|
+
const cleanupWorktree = async (repositoryRoot, worktreePath, tempRoot) => {
|
|
145
|
+
try {
|
|
146
|
+
runGit(['worktree', 'remove', '--force', worktreePath], repositoryRoot);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
await rm(worktreePath, {
|
|
150
|
+
force: true,
|
|
151
|
+
recursive: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
await rm(tempRoot, {
|
|
155
|
+
force: true,
|
|
156
|
+
recursive: true,
|
|
157
|
+
});
|
|
158
|
+
};
|
|
159
|
+
export const deployGithubPages = async (options = {}) => {
|
|
160
|
+
const resolved = resolveOptions(options);
|
|
161
|
+
assertSafeGitRefPart(resolved.sourceBranch, 'source branch');
|
|
162
|
+
assertSafeGitRefPart(resolved.remote, 'remote');
|
|
163
|
+
assertSafeGitRefPart(resolved.deploymentBranch, 'deployment branch');
|
|
164
|
+
const repositoryRoot = getRepositoryRoot(resolved.cwd);
|
|
165
|
+
const buildDirectory = resolve(repositoryRoot, resolved.buildDir);
|
|
166
|
+
const buildDirectoryPathspec = getBuildDirectoryPathspec(repositoryRoot, buildDirectory);
|
|
167
|
+
const deploymentRef = `${resolved.remote}/${resolved.deploymentBranch}`;
|
|
168
|
+
assertBuildDirectoryIsUntracked(repositoryRoot, buildDirectoryPathspec, resolved.sourceBranch);
|
|
169
|
+
assertCleanWorkingTree(repositoryRoot);
|
|
170
|
+
assertSyncedBranch(repositoryRoot, resolved.sourceBranch, resolved.remote);
|
|
171
|
+
const deploymentBranchExists = remoteBranchExists(repositoryRoot, resolved.remote, resolved.deploymentBranch);
|
|
172
|
+
if (deploymentBranchExists) {
|
|
173
|
+
fetchRemoteBranch(repositoryRoot, resolved.remote, resolved.deploymentBranch);
|
|
174
|
+
}
|
|
175
|
+
await assertBuildDirectory(buildDirectory);
|
|
176
|
+
const tempRoot = await mkdtemp(join(tmpdir(), 'micazoyolli-gh-pages-'));
|
|
177
|
+
const worktreePath = join(tempRoot, basename(repositoryRoot));
|
|
178
|
+
try {
|
|
179
|
+
runGit(['worktree', 'add', '--detach', worktreePath, deploymentBranchExists ? deploymentRef : resolved.sourceBranch], repositoryRoot);
|
|
180
|
+
if (!deploymentBranchExists) {
|
|
181
|
+
runGit(['checkout', '--orphan', resolved.deploymentBranch], worktreePath);
|
|
182
|
+
}
|
|
183
|
+
await clearWorktree(worktreePath);
|
|
184
|
+
await cp(buildDirectory, worktreePath, {
|
|
185
|
+
force: true,
|
|
186
|
+
recursive: true,
|
|
187
|
+
});
|
|
188
|
+
const deploymentStatus = runGit(['status', '--porcelain'], worktreePath).stdout;
|
|
189
|
+
if (!deploymentStatus) {
|
|
190
|
+
return {
|
|
191
|
+
deploymentBranch: resolved.deploymentBranch,
|
|
192
|
+
initialDeployment: false,
|
|
193
|
+
pushed: false,
|
|
194
|
+
reason: 'Deployment content is unchanged.',
|
|
195
|
+
remote: resolved.remote,
|
|
196
|
+
repositoryRoot,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
runGit(['add', '--all'], worktreePath);
|
|
200
|
+
runGit(['commit', '-m', resolved.message], worktreePath);
|
|
201
|
+
const commit = runGit(['rev-parse', '--short', 'HEAD'], worktreePath).stdout;
|
|
202
|
+
runGit(['push', resolved.remote, `HEAD:${resolved.deploymentBranch}`], worktreePath);
|
|
203
|
+
fetchRemoteBranch(repositoryRoot, resolved.remote, resolved.deploymentBranch);
|
|
204
|
+
return {
|
|
205
|
+
commit,
|
|
206
|
+
deploymentBranch: resolved.deploymentBranch,
|
|
207
|
+
initialDeployment: !deploymentBranchExists,
|
|
208
|
+
pushed: true,
|
|
209
|
+
remote: resolved.remote,
|
|
210
|
+
repositoryRoot,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await cleanupWorktree(repositoryRoot, worktreePath, tempRoot);
|
|
215
|
+
}
|
|
216
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@micazoyolli/foundation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Fundamentos frontend no visuales para el ecosistema técnico de Nad.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
},
|
|
27
27
|
"main": "dist/index.js",
|
|
28
28
|
"types": "dist/index.d.ts",
|
|
29
|
+
"bin": {
|
|
30
|
+
"micazoyolli-gh-pages-deploy": "dist/cli/deploy-github-pages.js"
|
|
31
|
+
},
|
|
29
32
|
"exports": {
|
|
30
33
|
".": {
|
|
31
34
|
"import": "./dist/index.js",
|
|
@@ -42,16 +45,17 @@
|
|
|
42
45
|
"access": "public"
|
|
43
46
|
},
|
|
44
47
|
"scripts": {
|
|
45
|
-
"build": "tsc -p tsconfig.json && yarn copy-scss",
|
|
48
|
+
"build": "tsc -p tsconfig.json && node scripts/mark-cli-executable.mjs && yarn copy-scss",
|
|
46
49
|
"clean": "rm -rf dist",
|
|
47
50
|
"copy-scss": "rsync -av --include='*/' --include='*.scss' --exclude='*' src/scss/ dist/scss/",
|
|
48
|
-
"docs:build": "vitepress build docs",
|
|
51
|
+
"docs:build": "yarn build && node scripts/generate-docs-seo.mjs && vitepress build docs",
|
|
49
52
|
"docs:dev": "vitepress dev docs --host 0.0.0.0",
|
|
50
53
|
"docs:preview": "vitepress preview docs --host 0.0.0.0",
|
|
51
54
|
"prepack": "yarn build",
|
|
52
55
|
"test": "yarn build && node --test tests/*.test.mjs"
|
|
53
56
|
},
|
|
54
57
|
"devDependencies": {
|
|
58
|
+
"@types/node": "24.10.4",
|
|
55
59
|
"sass-embedded": "^1.100.0",
|
|
56
60
|
"typescript": "6.0.3",
|
|
57
61
|
"vitepress": "^1.6.4"
|