dsh-gh-pages-artifacts 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 +21 -0
- package/README.md +227 -0
- package/assets/artifact-pages/SKILL.md +84 -0
- package/bin/setup.mjs +270 -0
- package/client/client.js +564 -0
- package/cordis.patch.yml +15 -0
- package/icon.svg +6 -0
- package/lib/approval.d.ts +26 -0
- package/lib/approval.js +59 -0
- package/lib/config.d.ts +204 -0
- package/lib/config.js +241 -0
- package/lib/content.d.ts +43 -0
- package/lib/content.js +142 -0
- package/lib/github.d.ts +78 -0
- package/lib/github.js +190 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +48 -0
- package/lib/manifest.d.ts +88 -0
- package/lib/manifest.js +172 -0
- package/lib/names.d.ts +17 -0
- package/lib/names.js +17 -0
- package/lib/prompt.d.ts +15 -0
- package/lib/prompt.js +33 -0
- package/lib/registry.d.ts +70 -0
- package/lib/registry.js +151 -0
- package/lib/render.d.ts +43 -0
- package/lib/render.js +128 -0
- package/lib/service.d.ts +292 -0
- package/lib/service.js +1216 -0
- package/lib/skill.d.ts +16 -0
- package/lib/skill.js +59 -0
- package/lib/store.d.ts +167 -0
- package/lib/store.js +379 -0
- package/lib/tools.d.ts +10 -0
- package/lib/tools.js +320 -0
- package/lib/visibility.d.ts +34 -0
- package/lib/visibility.js +81 -0
- package/lib/web-routes.d.ts +29 -0
- package/lib/web-routes.js +84 -0
- package/locale/en.json +6 -0
- package/package.json +130 -0
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Artifact manifest stored on the Pages branch, plus id and asset-name rules. */
|
|
2
|
+
/** Repository-root path of the manifest. It sits outside the artifact folders. */
|
|
3
|
+
export const MANIFEST_PATH = '.dsh-artifacts.json';
|
|
4
|
+
/** File holding the served page. */
|
|
5
|
+
export const PAGE_FILE = 'index.html';
|
|
6
|
+
/** File holding the Markdown source of a markdown artifact. */
|
|
7
|
+
export const SOURCE_FILE = 'source.md';
|
|
8
|
+
/** Valid artifact id: lowercase letters, digits, and inner hyphens, 1-64 characters. */
|
|
9
|
+
export const ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
10
|
+
/** Ids that would collide with site-level files or common folders. */
|
|
11
|
+
export const RESERVED_IDS = new Set([
|
|
12
|
+
'404', 'index', 'assets', 'static', 'api', 'robots', 'sitemap', 'favicon', 'cname',
|
|
13
|
+
]);
|
|
14
|
+
const ASSET_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
15
|
+
/** Error for a manifest that cannot be read safely. */
|
|
16
|
+
export class ManifestError extends Error {
|
|
17
|
+
name = 'ManifestError';
|
|
18
|
+
}
|
|
19
|
+
/** @returns an empty manifest. */
|
|
20
|
+
export function emptyManifest() {
|
|
21
|
+
return { version: 1, artifacts: {}, tombstones: [] };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Parse and validate manifest text. A malformed manifest is never overwritten silently.
|
|
25
|
+
* @param text - file content.
|
|
26
|
+
* @returns the manifest.
|
|
27
|
+
* @throws ManifestError when the content is not a version-1 manifest.
|
|
28
|
+
*/
|
|
29
|
+
export function parseManifest(text) {
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new ManifestError(`${MANIFEST_PATH} is not valid JSON (${error.message}); fix or remove it on the Pages branch`);
|
|
36
|
+
}
|
|
37
|
+
if (!isRecord(raw) || raw['version'] !== 1 || !isRecord(raw['artifacts'])) {
|
|
38
|
+
throw new ManifestError(`${MANIFEST_PATH} is not a version 1 artifact manifest; fix or remove it on the Pages branch`);
|
|
39
|
+
}
|
|
40
|
+
const artifacts = {};
|
|
41
|
+
for (const [id, value] of Object.entries(raw['artifacts'])) {
|
|
42
|
+
artifacts[id] = parseRecord(id, value);
|
|
43
|
+
}
|
|
44
|
+
const tombstonesRaw = raw['tombstones'] ?? [];
|
|
45
|
+
if (!Array.isArray(tombstonesRaw) || !tombstonesRaw.every(item => typeof item === 'string')) {
|
|
46
|
+
throw new ManifestError(`${MANIFEST_PATH} has an invalid "tombstones" list`);
|
|
47
|
+
}
|
|
48
|
+
return { version: 1, artifacts, tombstones: [...new Set(tombstonesRaw)] };
|
|
49
|
+
}
|
|
50
|
+
function parseRecord(id, value) {
|
|
51
|
+
const fail = (what) => {
|
|
52
|
+
throw new ManifestError(`${MANIFEST_PATH} entry "${id}" has an invalid ${what}`);
|
|
53
|
+
};
|
|
54
|
+
if (!isRecord(value))
|
|
55
|
+
return fail('record');
|
|
56
|
+
if (value['id'] !== id || !ID_PATTERN.test(id))
|
|
57
|
+
fail('id');
|
|
58
|
+
if (typeof value['title'] !== 'string')
|
|
59
|
+
fail('title');
|
|
60
|
+
if (value['kind'] !== 'html' && value['kind'] !== 'markdown')
|
|
61
|
+
fail('kind');
|
|
62
|
+
if (typeof value['createdAt'] !== 'string' || typeof value['updatedAt'] !== 'string')
|
|
63
|
+
fail('timestamp');
|
|
64
|
+
if (typeof value['rev'] !== 'number' || !Number.isSafeInteger(value['rev']) || value['rev'] < 1)
|
|
65
|
+
fail('rev');
|
|
66
|
+
const files = value['files'];
|
|
67
|
+
if (!Array.isArray(files) || !files.every(file => typeof file === 'string' && isSafeRelativePath(file)))
|
|
68
|
+
fail('files list');
|
|
69
|
+
if (value['description'] !== undefined && typeof value['description'] !== 'string')
|
|
70
|
+
fail('description');
|
|
71
|
+
const record = {
|
|
72
|
+
id,
|
|
73
|
+
title: value['title'],
|
|
74
|
+
kind: value['kind'],
|
|
75
|
+
createdAt: value['createdAt'],
|
|
76
|
+
updatedAt: value['updatedAt'],
|
|
77
|
+
rev: value['rev'],
|
|
78
|
+
files: [...files].sort(),
|
|
79
|
+
};
|
|
80
|
+
if (typeof value['description'] === 'string')
|
|
81
|
+
record.description = value['description'];
|
|
82
|
+
return record;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Serialize a manifest deterministically so diffs stay readable.
|
|
86
|
+
* @param manifest - manifest to write.
|
|
87
|
+
* @returns JSON text with a trailing newline.
|
|
88
|
+
*/
|
|
89
|
+
export function serializeManifest(manifest) {
|
|
90
|
+
const artifacts = {};
|
|
91
|
+
for (const id of Object.keys(manifest.artifacts).sort()) {
|
|
92
|
+
const record = manifest.artifacts[id];
|
|
93
|
+
if (record === undefined)
|
|
94
|
+
continue;
|
|
95
|
+
const ordered = {
|
|
96
|
+
id: record.id,
|
|
97
|
+
title: record.title,
|
|
98
|
+
kind: record.kind,
|
|
99
|
+
createdAt: record.createdAt,
|
|
100
|
+
updatedAt: record.updatedAt,
|
|
101
|
+
rev: record.rev,
|
|
102
|
+
files: [...record.files].sort(),
|
|
103
|
+
};
|
|
104
|
+
if (record.description !== undefined)
|
|
105
|
+
ordered.description = record.description;
|
|
106
|
+
artifacts[id] = ordered;
|
|
107
|
+
}
|
|
108
|
+
const tombstones = [...new Set(manifest.tombstones)].sort();
|
|
109
|
+
return `${JSON.stringify({ version: 1, artifacts, tombstones }, null, 2)}\n`;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Turn a title into a URL slug: lowercase ASCII letters and digits joined by hyphens.
|
|
113
|
+
* @param title - free text.
|
|
114
|
+
* @param maxLength - longest result.
|
|
115
|
+
* @returns the slug, or 'artifact' when nothing usable remains.
|
|
116
|
+
*/
|
|
117
|
+
export function slugify(title, maxLength = 40) {
|
|
118
|
+
const slug = title
|
|
119
|
+
.normalize('NFKD')
|
|
120
|
+
.replace(/[̀-ͯ]/g, '')
|
|
121
|
+
.toLowerCase()
|
|
122
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
123
|
+
.replace(/^-+|-+$/g, '')
|
|
124
|
+
.slice(0, maxLength)
|
|
125
|
+
.replace(/-+$/g, '');
|
|
126
|
+
return slug === '' ? 'artifact' : slug;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Build a fresh id from a title plus a random suffix, so URLs are readable but not guessable.
|
|
130
|
+
* @param title - artifact title.
|
|
131
|
+
* @param suffix - random lowercase base32 text.
|
|
132
|
+
*/
|
|
133
|
+
export function idFromTitle(title, suffix) {
|
|
134
|
+
return `${slugify(title)}-${suffix}`;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Check a caller-chosen id.
|
|
138
|
+
* @param id - candidate id.
|
|
139
|
+
* @returns an error message, or undefined when valid.
|
|
140
|
+
*/
|
|
141
|
+
export function idProblem(id) {
|
|
142
|
+
if (!ID_PATTERN.test(id)) {
|
|
143
|
+
return `"${id}" is not a valid artifact id: use 1-64 lowercase letters, digits, and inner hyphens`;
|
|
144
|
+
}
|
|
145
|
+
if (RESERVED_IDS.has(id))
|
|
146
|
+
return `"${id}" is reserved; choose another id`;
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check an asset name, the path of an extra file relative to the artifact folder.
|
|
151
|
+
* @param name - candidate name such as `chart.png` or `img/logo.svg`.
|
|
152
|
+
* @returns an error message, or undefined when valid.
|
|
153
|
+
*/
|
|
154
|
+
export function assetNameProblem(name) {
|
|
155
|
+
if (!isSafeRelativePath(name)) {
|
|
156
|
+
return `asset name "${name}" must be a relative path of segments made of letters, digits, '.', '_' or '-', each starting with a letter or digit (at most 4 levels)`;
|
|
157
|
+
}
|
|
158
|
+
if (name === PAGE_FILE || name === SOURCE_FILE)
|
|
159
|
+
return `asset name "${name}" is reserved for the page itself`;
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* @param path - candidate relative path.
|
|
164
|
+
* @returns whether it is 1-4 safe segments with no dot-segments.
|
|
165
|
+
*/
|
|
166
|
+
export function isSafeRelativePath(path) {
|
|
167
|
+
const segments = path.split('/');
|
|
168
|
+
return segments.length >= 1 && segments.length <= 4 && segments.every(segment => ASSET_SEGMENT.test(segment) && segment !== '.' && segment !== '..');
|
|
169
|
+
}
|
|
170
|
+
function isRecord(value) {
|
|
171
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
172
|
+
}
|
package/lib/names.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Tool names shared by the tool definitions, the runtime, and visibility rules. */
|
|
2
|
+
/** Tool that creates or updates an artifact. */
|
|
3
|
+
export declare const PUBLISH_TOOL = "artifact_publish";
|
|
4
|
+
/** Tool that lists artifacts. */
|
|
5
|
+
export declare const LIST_TOOL = "artifact_list";
|
|
6
|
+
/** Tool that reads an artifact back. */
|
|
7
|
+
export declare const READ_TOOL = "artifact_read";
|
|
8
|
+
/** Tool that deletes an artifact. */
|
|
9
|
+
export declare const DELETE_TOOL = "artifact_delete";
|
|
10
|
+
/** Tool that checks setup and deployment. */
|
|
11
|
+
export declare const STATUS_TOOL = "artifact_status";
|
|
12
|
+
/** Tool that shows or changes where new artifacts go. */
|
|
13
|
+
export declare const REPOSITORY_TOOL = "artifact_repository";
|
|
14
|
+
/** Every tool, in registration order. */
|
|
15
|
+
export declare const ALL_TOOLS: readonly ["artifact_publish", "artifact_list", "artifact_read", "artifact_delete", "artifact_status", "artifact_repository"];
|
|
16
|
+
/** Tools that change what is published. */
|
|
17
|
+
export declare const MUTATING_TOOLS: readonly ["artifact_publish", "artifact_delete"];
|
package/lib/names.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Tool names shared by the tool definitions, the runtime, and visibility rules. */
|
|
2
|
+
/** Tool that creates or updates an artifact. */
|
|
3
|
+
export const PUBLISH_TOOL = 'artifact_publish';
|
|
4
|
+
/** Tool that lists artifacts. */
|
|
5
|
+
export const LIST_TOOL = 'artifact_list';
|
|
6
|
+
/** Tool that reads an artifact back. */
|
|
7
|
+
export const READ_TOOL = 'artifact_read';
|
|
8
|
+
/** Tool that deletes an artifact. */
|
|
9
|
+
export const DELETE_TOOL = 'artifact_delete';
|
|
10
|
+
/** Tool that checks setup and deployment. */
|
|
11
|
+
export const STATUS_TOOL = 'artifact_status';
|
|
12
|
+
/** Tool that shows or changes where new artifacts go. */
|
|
13
|
+
export const REPOSITORY_TOOL = 'artifact_repository';
|
|
14
|
+
/** Every tool, in registration order. */
|
|
15
|
+
export const ALL_TOOLS = [PUBLISH_TOOL, LIST_TOOL, READ_TOOL, DELETE_TOOL, STATUS_TOOL, REPOSITORY_TOOL];
|
|
16
|
+
/** Tools that change what is published. */
|
|
17
|
+
export const MUTATING_TOOLS = [PUBLISH_TOOL, DELETE_TOOL];
|
package/lib/prompt.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Short, static system-prompt guidance, shown only to Agents that can see the publish tool. */
|
|
2
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
+
/** Order between first-party tool guidance (up to 3100) and the tools SDK (5000). */
|
|
4
|
+
export declare const PROMPT_ORDER = 3250;
|
|
5
|
+
/**
|
|
6
|
+
* Guidance text. Static per configuration so the provider prefix cache stays warm.
|
|
7
|
+
* @param withSkill - whether the bundled artifact-pages skill is registered.
|
|
8
|
+
*/
|
|
9
|
+
export declare function promptText(withSkill: boolean): string;
|
|
10
|
+
/**
|
|
11
|
+
* Register the guidance section.
|
|
12
|
+
* @param ctx - context exposing `systemPrompt` and `tools`.
|
|
13
|
+
* @param withSkill - whether to point at the bundled skill.
|
|
14
|
+
*/
|
|
15
|
+
export declare function registerPrompt(ctx: Context, withSkill: boolean): void;
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { PUBLISH_TOOL } from './names.js';
|
|
2
|
+
/** Order between first-party tool guidance (up to 3100) and the tools SDK (5000). */
|
|
3
|
+
export const PROMPT_ORDER = 3250;
|
|
4
|
+
/**
|
|
5
|
+
* Guidance text. Static per configuration so the provider prefix cache stays warm.
|
|
6
|
+
* @param withSkill - whether the bundled artifact-pages skill is registered.
|
|
7
|
+
*/
|
|
8
|
+
export function promptText(withSkill) {
|
|
9
|
+
return [
|
|
10
|
+
'# Shareable artifacts',
|
|
11
|
+
'You can publish web pages and documents as shareable links on GitHub Pages with artifact_publish, and manage them with artifact_list, artifact_read, artifact_delete, artifact_status, and artifact_repository.',
|
|
12
|
+
'- Use them when the user wants something they can open in a browser or share: a report, dashboard, visualization, demo, or long document.',
|
|
13
|
+
'- Published artifacts are public. Never publish secrets, credentials, or private data unless the user explicitly asks to share them.',
|
|
14
|
+
'- Write the page to a workspace file first and pass path. Update an existing artifact by passing its id, so the link stays the same.',
|
|
15
|
+
...withSkill ? ['- For design and structure guidance, load the artifact-pages skill before writing the page.'] : [],
|
|
16
|
+
'- After publishing, give the user the link as Markdown, e.g. [Title](url). When asked what was published, use artifact_list and show the links.',
|
|
17
|
+
'- When the user wants to choose where artifacts go (one new repository per artifact, or a repository they name), use artifact_repository.',
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Register the guidance section.
|
|
22
|
+
* @param ctx - context exposing `systemPrompt` and `tools`.
|
|
23
|
+
* @param withSkill - whether to point at the bundled skill.
|
|
24
|
+
*/
|
|
25
|
+
export function registerPrompt(ctx, withSkill) {
|
|
26
|
+
const text = promptText(withSkill);
|
|
27
|
+
ctx.systemPrompt.section({
|
|
28
|
+
name: 'gh-pages-artifacts',
|
|
29
|
+
order: PROMPT_ORDER,
|
|
30
|
+
interpolate: false,
|
|
31
|
+
text: ({ scope }) => ctx.tools.get(PUBLISH_TOOL, scope) === undefined ? '' : text,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { RepoRef, RepoStrategy } from './config.js';
|
|
2
|
+
import type { ArtifactKind } from './manifest.js';
|
|
3
|
+
/** Where one artifact lives. */
|
|
4
|
+
export interface ArtifactLocation {
|
|
5
|
+
readonly owner: string;
|
|
6
|
+
readonly repo: string;
|
|
7
|
+
readonly branch: string;
|
|
8
|
+
readonly siteDir: string;
|
|
9
|
+
readonly pathPrefix: string;
|
|
10
|
+
readonly layout: 'folder' | 'root';
|
|
11
|
+
}
|
|
12
|
+
/** One tracked artifact. */
|
|
13
|
+
export interface RegistryEntry {
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
kind: ArtifactKind;
|
|
17
|
+
description?: string;
|
|
18
|
+
url: string;
|
|
19
|
+
location: ArtifactLocation;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
rev: number;
|
|
23
|
+
status: 'published' | 'deleted';
|
|
24
|
+
deletedAt?: string;
|
|
25
|
+
}
|
|
26
|
+
/** A shared repository the user linked at runtime. */
|
|
27
|
+
export interface RepositoryLink extends RepoRef {
|
|
28
|
+
readonly linkedAt: string;
|
|
29
|
+
}
|
|
30
|
+
/** Registry file contents. */
|
|
31
|
+
export interface RegistryData {
|
|
32
|
+
version: 1;
|
|
33
|
+
/** Runtime choices that override the plugin configuration. */
|
|
34
|
+
settings: {
|
|
35
|
+
strategy?: RepoStrategy;
|
|
36
|
+
link?: RepositoryLink;
|
|
37
|
+
};
|
|
38
|
+
artifacts: Record<string, RegistryEntry>;
|
|
39
|
+
}
|
|
40
|
+
/** File names inside the registry folder. */
|
|
41
|
+
export declare const REGISTRY_FILE = "registry.json";
|
|
42
|
+
export declare const INDEX_FILE = "index.html";
|
|
43
|
+
/** Reads and updates the registry file. */
|
|
44
|
+
export declare class Registry {
|
|
45
|
+
readonly dir: string;
|
|
46
|
+
private readonly log?;
|
|
47
|
+
private queue;
|
|
48
|
+
constructor(dir: string, log?: ((message: string) => void) | undefined);
|
|
49
|
+
/** Path of the registry JSON file. */
|
|
50
|
+
get file(): string;
|
|
51
|
+
/** Path of the generated index page. */
|
|
52
|
+
get indexFile(): string;
|
|
53
|
+
/** @returns the current registry contents (empty when the file does not exist yet). */
|
|
54
|
+
read(): Promise<RegistryData>;
|
|
55
|
+
/**
|
|
56
|
+
* Apply a change and write the file and the index page atomically.
|
|
57
|
+
* @param change - mutates the freshly read data.
|
|
58
|
+
* @returns the written data.
|
|
59
|
+
*/
|
|
60
|
+
update(change: (data: RegistryData) => void): Promise<RegistryData>;
|
|
61
|
+
}
|
|
62
|
+
/** @returns an empty registry. */
|
|
63
|
+
export declare function emptyRegistry(): RegistryData;
|
|
64
|
+
/** @returns the `owner/name` label of a location. */
|
|
65
|
+
export declare function repositoryName(location: RepoRef): string;
|
|
66
|
+
/**
|
|
67
|
+
* Render the index page: every tracked artifact with a clickable link, newest first.
|
|
68
|
+
* @param data - registry contents.
|
|
69
|
+
*/
|
|
70
|
+
export declare function renderIndex(data: RegistryData): string;
|
package/lib/registry.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local registry of everything this plugin published, across every repository, plus the user's
|
|
3
|
+
* runtime choices (strategy and linked repository). It lives in one JSON file next to a generated
|
|
4
|
+
* index.html with clickable links to every page. Writes are atomic (temp file + rename) and
|
|
5
|
+
* re-read the file first, so several dsh processes sharing a home do not lose each other's entries.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { escapeAttribute, escapeText } from './render.js';
|
|
10
|
+
/** File names inside the registry folder. */
|
|
11
|
+
export const REGISTRY_FILE = 'registry.json';
|
|
12
|
+
export const INDEX_FILE = 'index.html';
|
|
13
|
+
/** Reads and updates the registry file. */
|
|
14
|
+
export class Registry {
|
|
15
|
+
dir;
|
|
16
|
+
log;
|
|
17
|
+
queue = Promise.resolve();
|
|
18
|
+
constructor(dir, log) {
|
|
19
|
+
this.dir = dir;
|
|
20
|
+
this.log = log;
|
|
21
|
+
}
|
|
22
|
+
/** Path of the registry JSON file. */
|
|
23
|
+
get file() {
|
|
24
|
+
return join(this.dir, REGISTRY_FILE);
|
|
25
|
+
}
|
|
26
|
+
/** Path of the generated index page. */
|
|
27
|
+
get indexFile() {
|
|
28
|
+
return join(this.dir, INDEX_FILE);
|
|
29
|
+
}
|
|
30
|
+
/** @returns the current registry contents (empty when the file does not exist yet). */
|
|
31
|
+
async read() {
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = await readFile(this.file, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return emptyRegistry();
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return parseRegistry(JSON.parse(text));
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
// Keep the unreadable file for the user instead of overwriting it.
|
|
46
|
+
const aside = `${this.file}.unreadable-${Date.now()}`;
|
|
47
|
+
await rename(this.file, aside).catch(() => undefined);
|
|
48
|
+
this.log?.(`gh-pages-artifacts: ${this.file} was unreadable (${error.message}); moved it to ${aside} and started a new registry`);
|
|
49
|
+
return emptyRegistry();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Apply a change and write the file and the index page atomically.
|
|
54
|
+
* @param change - mutates the freshly read data.
|
|
55
|
+
* @returns the written data.
|
|
56
|
+
*/
|
|
57
|
+
async update(change) {
|
|
58
|
+
const run = this.queue.then(async () => {
|
|
59
|
+
const data = await this.read();
|
|
60
|
+
change(data);
|
|
61
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
62
|
+
await writeAtomic(this.file, `${JSON.stringify(data, null, 2)}\n`);
|
|
63
|
+
await writeAtomic(this.indexFile, renderIndex(data));
|
|
64
|
+
return data;
|
|
65
|
+
});
|
|
66
|
+
this.queue = run.catch(() => undefined);
|
|
67
|
+
return await run;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function writeAtomic(path, text) {
|
|
71
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
72
|
+
await writeFile(temp, text, { mode: 0o600 });
|
|
73
|
+
await rename(temp, path);
|
|
74
|
+
}
|
|
75
|
+
/** @returns an empty registry. */
|
|
76
|
+
export function emptyRegistry() {
|
|
77
|
+
return { version: 1, settings: {}, artifacts: {} };
|
|
78
|
+
}
|
|
79
|
+
function parseRegistry(raw) {
|
|
80
|
+
if (typeof raw !== 'object' || raw === null || raw.version !== 1)
|
|
81
|
+
throw new Error('not a version 1 registry');
|
|
82
|
+
const data = raw;
|
|
83
|
+
if (typeof data.artifacts !== 'object' || data.artifacts === null)
|
|
84
|
+
throw new Error('missing artifacts');
|
|
85
|
+
return { version: 1, settings: data.settings ?? {}, artifacts: data.artifacts };
|
|
86
|
+
}
|
|
87
|
+
/** @returns the `owner/name` label of a location. */
|
|
88
|
+
export function repositoryName(location) {
|
|
89
|
+
return `${location.owner}/${location.repo}`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Render the index page: every tracked artifact with a clickable link, newest first.
|
|
93
|
+
* @param data - registry contents.
|
|
94
|
+
*/
|
|
95
|
+
export function renderIndex(data) {
|
|
96
|
+
const entries = Object.values(data.artifacts).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
97
|
+
const published = entries.filter(entry => entry.status === 'published');
|
|
98
|
+
const rows = entries.map(entry => {
|
|
99
|
+
const repo = repositoryName(entry.location);
|
|
100
|
+
const title = entry.status === 'published'
|
|
101
|
+
? `<a href="${escapeAttribute(entry.url)}" target="_blank" rel="noopener">${escapeText(entry.title)}</a>`
|
|
102
|
+
: `<s>${escapeText(entry.title)}</s>`;
|
|
103
|
+
return `<tr class="${entry.status}">
|
|
104
|
+
<td>${title}${entry.description === undefined ? '' : `<div class="desc">${escapeText(entry.description)}</div>`}</td>
|
|
105
|
+
<td>${entry.kind}</td>
|
|
106
|
+
<td><a href="https://github.com/${escapeAttribute(repo)}" target="_blank" rel="noopener">${escapeText(repo)}</a></td>
|
|
107
|
+
<td>${entry.rev}</td>
|
|
108
|
+
<td><time datetime="${escapeAttribute(entry.updatedAt)}">${escapeText(entry.updatedAt.slice(0, 16).replace('T', ' '))}</time></td>
|
|
109
|
+
<td>${entry.status === 'published' ? 'live' : 'deleted'}</td>
|
|
110
|
+
</tr>`;
|
|
111
|
+
});
|
|
112
|
+
return `<!doctype html>
|
|
113
|
+
<html lang="en">
|
|
114
|
+
<head>
|
|
115
|
+
<meta charset="utf-8">
|
|
116
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
117
|
+
<title>Published artifacts</title>
|
|
118
|
+
<style>
|
|
119
|
+
:root{color-scheme:light dark;--bg:#fff;--fg:#1f2328;--muted:#59636e;--border:#d1d9e0;--link:#0969da;--head:#f6f8fa}
|
|
120
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--border:#3d444d;--link:#4493f8;--head:#151b23}}
|
|
121
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif}
|
|
122
|
+
main{max-width:72rem;margin:0 auto;padding:2rem 1rem}
|
|
123
|
+
h1{font-size:1.6rem;margin:0 0 .25rem}
|
|
124
|
+
p{color:var(--muted);margin:0 0 1.5rem}
|
|
125
|
+
.wrap{overflow-x:auto}
|
|
126
|
+
table{border-collapse:collapse;width:100%}
|
|
127
|
+
th,td{text-align:left;padding:.5rem .75rem;border-bottom:1px solid var(--border);vertical-align:top}
|
|
128
|
+
th{background:var(--head);font-weight:600;white-space:nowrap}
|
|
129
|
+
a{color:var(--link);text-decoration:none}
|
|
130
|
+
a:hover{text-decoration:underline}
|
|
131
|
+
.desc{color:var(--muted);font-size:.875rem}
|
|
132
|
+
tr.deleted td{color:var(--muted)}
|
|
133
|
+
</style>
|
|
134
|
+
</head>
|
|
135
|
+
<body>
|
|
136
|
+
<main>
|
|
137
|
+
<h1>Published artifacts</h1>
|
|
138
|
+
<p>${published.length} live, ${entries.length - published.length} deleted. Generated by dsh-gh-pages-artifacts.</p>
|
|
139
|
+
<div class="wrap">
|
|
140
|
+
<table>
|
|
141
|
+
<thead><tr><th>Artifact</th><th>Kind</th><th>Repository</th><th>Rev</th><th>Updated (UTC)</th><th>Status</th></tr></thead>
|
|
142
|
+
<tbody>
|
|
143
|
+
${rows.length === 0 ? '<tr><td colspan="6">Nothing published yet.</td></tr>' : rows.join('\n')}
|
|
144
|
+
</tbody>
|
|
145
|
+
</table>
|
|
146
|
+
</div>
|
|
147
|
+
</main>
|
|
148
|
+
</body>
|
|
149
|
+
</html>
|
|
150
|
+
`;
|
|
151
|
+
}
|
package/lib/render.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Page decorations controlled by configuration. */
|
|
2
|
+
export interface PageOptions {
|
|
3
|
+
/** Add a robots noindex meta tag. */
|
|
4
|
+
readonly noindex: boolean;
|
|
5
|
+
/** Content-Security-Policy for a meta tag; '' for none. */
|
|
6
|
+
readonly csp: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Render a Markdown document into a complete, styled HTML page. Raw HTML in the Markdown is
|
|
10
|
+
* escaped and unsafe link protocols are dropped (micromark defaults).
|
|
11
|
+
* @param input - title, optional description, Markdown source.
|
|
12
|
+
* @param options - page decorations.
|
|
13
|
+
* @returns the HTML document.
|
|
14
|
+
*/
|
|
15
|
+
export declare function renderMarkdownPage(input: {
|
|
16
|
+
title: string;
|
|
17
|
+
description?: string | undefined;
|
|
18
|
+
markdown: string;
|
|
19
|
+
}, options: PageOptions): string;
|
|
20
|
+
/** Attribute that marks the tags this plugin injects, so they can be removed again. */
|
|
21
|
+
export declare const INJECTED_ATTRIBUTE = "data-dsh-artifacts";
|
|
22
|
+
/**
|
|
23
|
+
* Remove tags a previous publish injected, giving back the page as its author wrote it.
|
|
24
|
+
* @param html - served page.
|
|
25
|
+
* @returns the page without plugin-injected meta tags.
|
|
26
|
+
*/
|
|
27
|
+
export declare function stripInjected(html: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Prepare an HTML page for publishing: complete a fragment into a document and add the
|
|
30
|
+
* configured meta tags right after `<head>` so they apply before any other content. Tags from
|
|
31
|
+
* earlier publishes are removed first, so read-modify-publish cycles do not pile them up.
|
|
32
|
+
* @param input - title used when the source is a fragment, and the HTML source.
|
|
33
|
+
* @param options - page decorations.
|
|
34
|
+
* @returns the HTML document.
|
|
35
|
+
*/
|
|
36
|
+
export declare function prepareHtmlPage(input: {
|
|
37
|
+
title: string;
|
|
38
|
+
html: string;
|
|
39
|
+
}, options: PageOptions): string;
|
|
40
|
+
/** @param text - text for an HTML text node. */
|
|
41
|
+
export declare function escapeText(text: string): string;
|
|
42
|
+
/** @param text - text for a double-quoted HTML attribute. */
|
|
43
|
+
export declare function escapeAttribute(text: string): string;
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/** Turn artifact sources into the HTML that GitHub Pages serves. */
|
|
2
|
+
import { micromark } from 'micromark';
|
|
3
|
+
import { gfm, gfmHtml } from 'micromark-extension-gfm';
|
|
4
|
+
const GENERATOR = 'dsh-gh-pages-artifacts';
|
|
5
|
+
/**
|
|
6
|
+
* Render a Markdown document into a complete, styled HTML page. Raw HTML in the Markdown is
|
|
7
|
+
* escaped and unsafe link protocols are dropped (micromark defaults).
|
|
8
|
+
* @param input - title, optional description, Markdown source.
|
|
9
|
+
* @param options - page decorations.
|
|
10
|
+
* @returns the HTML document.
|
|
11
|
+
*/
|
|
12
|
+
export function renderMarkdownPage(input, options) {
|
|
13
|
+
const body = micromark(input.markdown, { extensions: [gfm()], htmlExtensions: [gfmHtml()] });
|
|
14
|
+
const head = [
|
|
15
|
+
'<meta charset="utf-8">',
|
|
16
|
+
...securityMeta(options),
|
|
17
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
18
|
+
`<meta name="generator" content="${GENERATOR}">`,
|
|
19
|
+
...input.description === undefined ? [] : [`<meta name="description" content="${escapeAttribute(input.description)}">`],
|
|
20
|
+
`<title>${escapeText(input.title)}</title>`,
|
|
21
|
+
`<style>${DOCUMENT_CSS}</style>`,
|
|
22
|
+
];
|
|
23
|
+
return `<!doctype html>\n<html lang="en">\n<head>\n${head.join('\n')}\n</head>\n<body>\n<main class="doc">\n${body}</main>\n</body>\n</html>\n`;
|
|
24
|
+
}
|
|
25
|
+
/** Attribute that marks the tags this plugin injects, so they can be removed again. */
|
|
26
|
+
export const INJECTED_ATTRIBUTE = 'data-dsh-artifacts';
|
|
27
|
+
const INJECTED_TAG = new RegExp(`\\r?\\n?[ \\t]*<meta\\b[^>]*\\b${INJECTED_ATTRIBUTE}\\b[^>]*>`, 'gi');
|
|
28
|
+
/**
|
|
29
|
+
* Remove tags a previous publish injected, giving back the page as its author wrote it.
|
|
30
|
+
* @param html - served page.
|
|
31
|
+
* @returns the page without plugin-injected meta tags.
|
|
32
|
+
*/
|
|
33
|
+
export function stripInjected(html) {
|
|
34
|
+
return html.replace(INJECTED_TAG, '');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Prepare an HTML page for publishing: complete a fragment into a document and add the
|
|
38
|
+
* configured meta tags right after `<head>` so they apply before any other content. Tags from
|
|
39
|
+
* earlier publishes are removed first, so read-modify-publish cycles do not pile them up.
|
|
40
|
+
* @param input - title used when the source is a fragment, and the HTML source.
|
|
41
|
+
* @param options - page decorations.
|
|
42
|
+
* @returns the HTML document.
|
|
43
|
+
*/
|
|
44
|
+
export function prepareHtmlPage(input, options) {
|
|
45
|
+
const html = stripInjected(input.html.replace(/^\uFEFF/, ''));
|
|
46
|
+
const additions = securityMeta(options);
|
|
47
|
+
const headOpen = firstTagOutsideComments(html, /<head(?:\s[^>]*)?>/gi);
|
|
48
|
+
if (headOpen !== undefined) {
|
|
49
|
+
const at = headOpen.index + headOpen.length;
|
|
50
|
+
return additions.length === 0 ? html : `${html.slice(0, at)}\n${additions.join('\n')}${html.slice(at)}`;
|
|
51
|
+
}
|
|
52
|
+
const htmlOpen = firstTagOutsideComments(html, /<html(?:\s[^>]*)?>/gi);
|
|
53
|
+
if (htmlOpen !== undefined) {
|
|
54
|
+
const at = htmlOpen.index + htmlOpen.length;
|
|
55
|
+
const head = ['<meta charset="utf-8">', ...additions];
|
|
56
|
+
if (!/<title[\s>]/i.test(html))
|
|
57
|
+
head.push(`<title>${escapeText(input.title)}</title>`);
|
|
58
|
+
return `${html.slice(0, at)}\n<head>\n${head.join('\n')}\n</head>${html.slice(at)}`;
|
|
59
|
+
}
|
|
60
|
+
const head = [
|
|
61
|
+
'<meta charset="utf-8">',
|
|
62
|
+
...additions,
|
|
63
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
64
|
+
`<meta name="generator" content="${GENERATOR}">`,
|
|
65
|
+
`<title>${escapeText(input.title)}</title>`,
|
|
66
|
+
];
|
|
67
|
+
const doctype = /^\s*<!doctype html[^>]*>/i.exec(html);
|
|
68
|
+
const content = doctype === null ? html : html.slice(doctype[0].length);
|
|
69
|
+
return `<!doctype html>\n<html lang="en">\n<head>\n${head.join('\n')}\n</head>\n<body>\n${content.trim()}\n</body>\n</html>\n`;
|
|
70
|
+
}
|
|
71
|
+
/** Find the first match of a tag pattern that is not inside an HTML comment. */
|
|
72
|
+
function firstTagOutsideComments(html, pattern) {
|
|
73
|
+
const comments = [];
|
|
74
|
+
for (const match of html.matchAll(/<!--[\s\S]*?(?:-->|$)/g))
|
|
75
|
+
comments.push([match.index, match.index + match[0].length]);
|
|
76
|
+
for (const match of html.matchAll(pattern)) {
|
|
77
|
+
if (!comments.some(([start, end]) => match.index >= start && match.index < end))
|
|
78
|
+
return { index: match.index, length: match[0].length };
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
function securityMeta(options) {
|
|
83
|
+
const tags = [];
|
|
84
|
+
if (options.csp !== '')
|
|
85
|
+
tags.push(`<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(options.csp)}" ${INJECTED_ATTRIBUTE}>`);
|
|
86
|
+
// Crawlers apply the most restrictive robots directive, so this wins over any the page sets.
|
|
87
|
+
if (options.noindex)
|
|
88
|
+
tags.push(`<meta name="robots" content="noindex, nofollow" ${INJECTED_ATTRIBUTE}>`);
|
|
89
|
+
return tags;
|
|
90
|
+
}
|
|
91
|
+
/** @param text - text for an HTML text node. */
|
|
92
|
+
export function escapeText(text) {
|
|
93
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
94
|
+
}
|
|
95
|
+
/** @param text - text for a double-quoted HTML attribute. */
|
|
96
|
+
export function escapeAttribute(text) {
|
|
97
|
+
return escapeText(text).replace(/"/g, '"');
|
|
98
|
+
}
|
|
99
|
+
const DOCUMENT_CSS = `
|
|
100
|
+
:root{color-scheme:light dark;--bg:#ffffff;--fg:#1f2328;--muted:#59636e;--border:#d1d9e0;--code-bg:#f6f8fa;--link:#0969da;--accent-bg:#f6f8fa}
|
|
101
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--border:#3d444d;--code-bg:#151b23;--link:#4493f8;--accent-bg:#151b23}}
|
|
102
|
+
*{box-sizing:border-box}
|
|
103
|
+
html{-webkit-text-size-adjust:100%}
|
|
104
|
+
body{margin:0;background:var(--bg);color:var(--fg);font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";overflow-wrap:break-word}
|
|
105
|
+
.doc{max-width:46rem;margin:0 auto;padding:3rem 1rem 4rem}
|
|
106
|
+
h1,h2,h3,h4,h5,h6{line-height:1.25;margin:2rem 0 1rem;font-weight:600}
|
|
107
|
+
h1{font-size:2rem;margin-top:0;padding-bottom:.3em;border-bottom:1px solid var(--border)}
|
|
108
|
+
h2{font-size:1.5rem;padding-bottom:.3em;border-bottom:1px solid var(--border)}
|
|
109
|
+
h3{font-size:1.25rem}
|
|
110
|
+
p,ul,ol,dl,table,pre,blockquote{margin:0 0 1rem}
|
|
111
|
+
a{color:var(--link);text-decoration:none}
|
|
112
|
+
a:hover{text-decoration:underline}
|
|
113
|
+
img,video{max-width:100%;height:auto}
|
|
114
|
+
hr{border:0;border-top:1px solid var(--border);margin:2rem 0}
|
|
115
|
+
blockquote{padding:0 1em;color:var(--muted);border-left:.25em solid var(--border)}
|
|
116
|
+
code,pre{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:.875em}
|
|
117
|
+
code{padding:.2em .4em;background:var(--code-bg);border-radius:6px}
|
|
118
|
+
pre{padding:1rem;overflow:auto;background:var(--code-bg);border-radius:6px;line-height:1.45}
|
|
119
|
+
pre code{padding:0;background:transparent;font-size:inherit}
|
|
120
|
+
table{display:block;width:max-content;max-width:100%;overflow:auto;border-collapse:collapse}
|
|
121
|
+
th,td{padding:.4rem .8rem;border:1px solid var(--border)}
|
|
122
|
+
th{font-weight:600;background:var(--accent-bg)}
|
|
123
|
+
li+li{margin-top:.25em}
|
|
124
|
+
ul.contains-task-list{list-style:none;padding-left:1.2em}
|
|
125
|
+
input[type=checkbox]{margin:0 .4em 0 -1.2em;vertical-align:middle}
|
|
126
|
+
.footnotes{font-size:.875rem;color:var(--muted)}
|
|
127
|
+
@media print{body{background:#fff;color:#000}.doc{padding:0}}
|
|
128
|
+
`.trim();
|