@magland/mochi 0.3.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/LICENSE +201 -0
- package/README.md +108 -0
- package/dist/ansi.js +174 -0
- package/dist/api/admin.js +416 -0
- package/dist/api/auth.js +166 -0
- package/dist/api/backup.js +598 -0
- package/dist/api/ci.js +336 -0
- package/dist/api/contents.js +339 -0
- package/dist/api/issues.js +165 -0
- package/dist/api/pulls.js +244 -0
- package/dist/api/releases.js +83 -0
- package/dist/api/repos.js +156 -0
- package/dist/api/write.js +518 -0
- package/dist/api.js +326 -0
- package/dist/assets.js +29 -0
- package/dist/atom.js +32 -0
- package/dist/atomic.js +171 -0
- package/dist/avatar.js +81 -0
- package/dist/browse.js +630 -0
- package/dist/build-info.json +4 -0
- package/dist/ci/actionref.js +86 -0
- package/dist/ci/api.js +829 -0
- package/dist/ci/artifacts.js +201 -0
- package/dist/ci/dispatch.js +30 -0
- package/dist/ci/engine.js +1321 -0
- package/dist/ci/expr.js +526 -0
- package/dist/ci/manual.js +199 -0
- package/dist/ci/present.js +82 -0
- package/dist/ci/protocol.js +6 -0
- package/dist/ci/runners.js +256 -0
- package/dist/ci/runs.js +208 -0
- package/dist/ci/trigger.js +28 -0
- package/dist/ci/views.js +441 -0
- package/dist/ci/wake.js +194 -0
- package/dist/ci/web.js +617 -0
- package/dist/ci/workflow.js +436 -0
- package/dist/cli/admin-cmd.js +324 -0
- package/dist/cli/api-cmd.js +128 -0
- package/dist/cli/backup-cmd.js +1500 -0
- package/dist/cli/exit.js +69 -0
- package/dist/cli/input.js +64 -0
- package/dist/cli/issue-cmd.js +243 -0
- package/dist/cli/output.js +93 -0
- package/dist/cli/parse.js +317 -0
- package/dist/cli/pr-cmd.js +289 -0
- package/dist/cli/release-cmd.js +171 -0
- package/dist/cli/repo-cmd.js +763 -0
- package/dist/cli/repo.js +101 -0
- package/dist/cli/run-cmd.js +438 -0
- package/dist/cli/target.js +54 -0
- package/dist/cli-api.js +84 -0
- package/dist/compare.js +111 -0
- package/dist/config.js +212 -0
- package/dist/credentials.js +235 -0
- package/dist/deploy-cli.js +859 -0
- package/dist/deploy-runner-cli.js +592 -0
- package/dist/diff.js +171 -0
- package/dist/discussion.js +253 -0
- package/dist/egress.js +559 -0
- package/dist/filecache.js +68 -0
- package/dist/find.js +162 -0
- package/dist/forms.js +737 -0
- package/dist/git.js +547 -0
- package/dist/githttp.js +428 -0
- package/dist/html.js +87 -0
- package/dist/icons.js +101 -0
- package/dist/import-cli.js +316 -0
- package/dist/index.js +752 -0
- package/dist/issues.js +308 -0
- package/dist/issueweb.js +447 -0
- package/dist/job-cli.js +197 -0
- package/dist/jobtoken.js +96 -0
- package/dist/languages.js +383 -0
- package/dist/layout.js +100 -0
- package/dist/lfs.js +438 -0
- package/dist/lfsstore.js +425 -0
- package/dist/limit.js +259 -0
- package/dist/logo.js +61 -0
- package/dist/markdown.js +382 -0
- package/dist/migrate.js +334 -0
- package/dist/multipart.js +90 -0
- package/dist/ops.js +869 -0
- package/dist/pagescript.js +465 -0
- package/dist/perms.js +370 -0
- package/dist/pointer.js +55 -0
- package/dist/profile.js +106 -0
- package/dist/pulls.js +320 -0
- package/dist/pullweb.js +461 -0
- package/dist/redirects.js +455 -0
- package/dist/releases.js +435 -0
- package/dist/render.js +233 -0
- package/dist/runner/actions.js +448 -0
- package/dist/runner/client.js +428 -0
- package/dist/runner/context.js +247 -0
- package/dist/runner/docker.js +197 -0
- package/dist/runner/externals.js +175 -0
- package/dist/runner/job.js +290 -0
- package/dist/runner/manual-run.js +272 -0
- package/dist/runner/overrides.js +554 -0
- package/dist/runner/steps.js +571 -0
- package/dist/runner/wake.js +84 -0
- package/dist/runner-cli.js +405 -0
- package/dist/scan.js +231 -0
- package/dist/server.js +424 -0
- package/dist/session.js +267 -0
- package/dist/site.js +259 -0
- package/dist/siteshost.js +94 -0
- package/dist/source.js +90 -0
- package/dist/style.js +1295 -0
- package/dist/themes.js +369 -0
- package/dist/vault.js +442 -0
- package/dist/version.js +88 -0
- package/dist/views.js +1007 -0
- package/dist/web.js +182 -0
- package/dist/webops.js +1402 -0
- package/package.json +71 -0
package/dist/releases.js
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.releasesDir = releasesDir;
|
|
37
|
+
exports.readRelease = readRelease;
|
|
38
|
+
exports.listReleases = listReleases;
|
|
39
|
+
exports.releaseTags = releaseTags;
|
|
40
|
+
exports.saveRelease = saveRelease;
|
|
41
|
+
exports.deleteRelease = deleteRelease;
|
|
42
|
+
exports.hasReleases = hasReleases;
|
|
43
|
+
exports.registerReleases = registerReleases;
|
|
44
|
+
const fs = __importStar(require("fs"));
|
|
45
|
+
const path = __importStar(require("path"));
|
|
46
|
+
const YAML = __importStar(require("yaml"));
|
|
47
|
+
const atom_1 = require("./atom");
|
|
48
|
+
const atomic_1 = require("./atomic");
|
|
49
|
+
const git_1 = require("./git");
|
|
50
|
+
const html_1 = require("./html");
|
|
51
|
+
const icons_1 = require("./icons");
|
|
52
|
+
const markdown_1 = require("./markdown");
|
|
53
|
+
const render_1 = require("./render");
|
|
54
|
+
const session_1 = require("./session");
|
|
55
|
+
const layout_1 = require("./layout");
|
|
56
|
+
const scan_1 = require("./scan");
|
|
57
|
+
const views_1 = require("./views");
|
|
58
|
+
const web_1 = require("./web");
|
|
59
|
+
const MAX_NAME = 200;
|
|
60
|
+
const MAX_NOTES = 64 * 1024;
|
|
61
|
+
function releasesDir(root, collection, repo) {
|
|
62
|
+
return (0, layout_1.repoPath)(root, collection, `${repo}.releases`);
|
|
63
|
+
}
|
|
64
|
+
function fileFor(dir, tag) {
|
|
65
|
+
// encodeURIComponent leaves no slash and no dot-dot, so the name cannot
|
|
66
|
+
// climb out of the directory however the tag was written.
|
|
67
|
+
return path.join(dir, `${encodeURIComponent(tag)}.md`);
|
|
68
|
+
}
|
|
69
|
+
function parseRelease(text, tag) {
|
|
70
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
71
|
+
let head = {};
|
|
72
|
+
let body = text;
|
|
73
|
+
if (m) {
|
|
74
|
+
try {
|
|
75
|
+
head = (YAML.parse(m[1]) ?? {});
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
head = {};
|
|
79
|
+
}
|
|
80
|
+
body = m[2];
|
|
81
|
+
}
|
|
82
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
83
|
+
return {
|
|
84
|
+
tag: str(head.tag) || tag,
|
|
85
|
+
name: str(head.name),
|
|
86
|
+
author: str(head.author),
|
|
87
|
+
created: str(head.created),
|
|
88
|
+
prerelease: head.prerelease === true,
|
|
89
|
+
body,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function readRelease(root, collection, repo, tag) {
|
|
93
|
+
if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(repo) || !(0, git_1.isValidRefName)(tag))
|
|
94
|
+
return null;
|
|
95
|
+
try {
|
|
96
|
+
const text = fs.readFileSync(fileFor(releasesDir(root, collection, repo), tag), 'utf8');
|
|
97
|
+
return parseRelease(text, tag);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Every release in the vault for this repository, newest first by tag date. */
|
|
104
|
+
function listReleases(root, collection, repo) {
|
|
105
|
+
const dir = releasesDir(root, collection, repo);
|
|
106
|
+
let names;
|
|
107
|
+
try {
|
|
108
|
+
names = fs.readdirSync(dir);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const name of names) {
|
|
115
|
+
if (!name.endsWith('.md'))
|
|
116
|
+
continue;
|
|
117
|
+
let tag;
|
|
118
|
+
try {
|
|
119
|
+
tag = decodeURIComponent(name.slice(0, -3));
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const release = readRelease(root, collection, repo, tag);
|
|
125
|
+
if (release)
|
|
126
|
+
out.push(release);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The tag of every release, from the directory alone.
|
|
132
|
+
*
|
|
133
|
+
* A release's file is named for its tag, so counting releases and asking
|
|
134
|
+
* whether a tag has one needs no file opened and no front matter parsed --
|
|
135
|
+
* which is all that a repository page wants, and it wants it on every page.
|
|
136
|
+
* listReleases reads and YAML-parses every release to answer the same two
|
|
137
|
+
* questions, which at a hundred releases is 19ms and a hundred opens spent on
|
|
138
|
+
* bodies nobody looks at.
|
|
139
|
+
*/
|
|
140
|
+
function releaseTags(root, collection, repo) {
|
|
141
|
+
let names;
|
|
142
|
+
try {
|
|
143
|
+
names = fs.readdirSync(releasesDir(root, collection, repo));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const tags = [];
|
|
149
|
+
for (const name of names) {
|
|
150
|
+
if (!name.endsWith('.md'))
|
|
151
|
+
continue;
|
|
152
|
+
try {
|
|
153
|
+
tags.push(decodeURIComponent(name.slice(0, -3)));
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// A name that is not a tag this forge wrote; listReleases skips it too.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return tags;
|
|
160
|
+
}
|
|
161
|
+
function saveRelease(root, collection, repo, release) {
|
|
162
|
+
const dir = releasesDir(root, collection, repo);
|
|
163
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
164
|
+
const head = YAML.stringify({
|
|
165
|
+
tag: release.tag,
|
|
166
|
+
name: release.name,
|
|
167
|
+
author: release.author,
|
|
168
|
+
created: release.created,
|
|
169
|
+
prerelease: release.prerelease,
|
|
170
|
+
}).trimEnd();
|
|
171
|
+
(0, atomic_1.writeFileAtomic)(fileFor(dir, release.tag), `---\n${head}\n---\n${release.body}\n`);
|
|
172
|
+
}
|
|
173
|
+
function deleteRelease(root, collection, repo, tag) {
|
|
174
|
+
if (!(0, git_1.isValidRefName)(tag))
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
fs.unlinkSync(fileFor(releasesDir(root, collection, repo), tag));
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function hasReleases(root, collection, repo) {
|
|
185
|
+
try {
|
|
186
|
+
return fs.readdirSync(releasesDir(root, collection, repo)).some((n) => n.endsWith('.md'));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// ---- pages ----
|
|
193
|
+
function releaseTitle(release) {
|
|
194
|
+
return release.name || release.tag;
|
|
195
|
+
}
|
|
196
|
+
/** The download links: the source at that tag, from the archive routes. */
|
|
197
|
+
function downloads(ctx, tag) {
|
|
198
|
+
const base = `${(0, views_1.repoUrl)(ctx)}/archive/${(0, views_1.encPath)(tag)}`;
|
|
199
|
+
const one = (ext, label) => (0, html_1.html) `<a class="release-download" href="${base}.${ext}">${(0, icons_1.icon)('file-zip')}<span>${label}</span></a>`;
|
|
200
|
+
return (0, html_1.html) `<div class="release-downloads">${one('zip', 'Source code (zip)')}${one('tar.gz', 'Source code (tar.gz)')}</div>`;
|
|
201
|
+
}
|
|
202
|
+
function releaseCard(ctx, release, ref, latest, notes) {
|
|
203
|
+
const base = (0, views_1.repoUrl)(ctx);
|
|
204
|
+
const chips = [
|
|
205
|
+
latest ? (0, html_1.raw)('<span class="chip chip-latest">Latest</span>') : '',
|
|
206
|
+
release.prerelease ? (0, html_1.raw)('<span class="chip chip-pre">Pre-release</span>') : '',
|
|
207
|
+
];
|
|
208
|
+
const edit = ctx.canPush
|
|
209
|
+
? (0, html_1.html) `<a class="btn" href="${base}/releases/new?tag=${encodeURIComponent(release.tag)}">${(0, icons_1.icon)('pencil')}<span>Edit</span></a>`
|
|
210
|
+
: '';
|
|
211
|
+
return (0, html_1.html) `<div class="release">
|
|
212
|
+
<div class="release-side">
|
|
213
|
+
<a class="release-tag" href="${base}/tree/${(0, views_1.encPath)(release.tag)}">${(0, icons_1.icon)('tag')}<b>${release.tag}</b></a>
|
|
214
|
+
${ref ? (0, html_1.html) `<div class="small muted">${(0, render_1.timeTag)(ref.date)}</div>` : ''}
|
|
215
|
+
${ref ? (0, html_1.html) `<a class="sha" href="${base}/commit/${ref.sha}">${ref.sha.slice(0, 7)}</a>` : ''}
|
|
216
|
+
</div>
|
|
217
|
+
<div class="release-main">
|
|
218
|
+
<div class="release-head"><h2><a href="${base}/releases/tag/${(0, views_1.encPath)(release.tag)}">${releaseTitle(release)}</a></h2>${chips}<span class="release-actions">${edit}</span></div>
|
|
219
|
+
<div class="muted small">${release.author ? (0, html_1.html) `${release.author} released this` : 'Released'} ${release.created ? (0, render_1.timeTag)(release.created) : ''}</div>
|
|
220
|
+
${notes ? (0, html_1.html) `<div class="markdown-body release-notes">${(0, html_1.raw)(notes)}</div>` : (0, html_1.raw)('<p class="muted">No notes.</p>')}
|
|
221
|
+
${downloads(ctx, release.tag)}
|
|
222
|
+
</div>
|
|
223
|
+
</div>`;
|
|
224
|
+
}
|
|
225
|
+
function releasesPage(ctx, cards, count) {
|
|
226
|
+
const base = (0, views_1.repoUrl)(ctx);
|
|
227
|
+
const newBtn = ctx.canPush
|
|
228
|
+
? (0, html_1.html) `<a class="btn btn-primary" href="${base}/releases/new">${(0, icons_1.icon)('plus')}<span>Draft a release</span></a>`
|
|
229
|
+
: '';
|
|
230
|
+
const feed = (0, html_1.html) `<a class="btn" href="${base}/releases.atom" title="Atom feed of releases">${(0, icons_1.icon)('rss')}<span>Feed</span></a>`;
|
|
231
|
+
const body = count === 0
|
|
232
|
+
? (0, html_1.html) `<div class="empty-state"><p>No releases yet.</p><p class="small">A release is notes attached to a tag. ${ctx.canPush ? 'Draft one above,' : 'Someone with push access can draft one,'} or browse the <a href="${base}/tags">tags</a>.</p></div>`
|
|
233
|
+
: cards;
|
|
234
|
+
const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'releases')}
|
|
235
|
+
<div class="page-head"><h1>Releases</h1><span class="right-group">${feed}${newBtn}</span></div>
|
|
236
|
+
${body}`;
|
|
237
|
+
return (0, views_1.layout)(`Releases - ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, `${base}/releases`));
|
|
238
|
+
}
|
|
239
|
+
function releaseFormPage(ctx, viewer, tags, editing, error) {
|
|
240
|
+
const base = (0, views_1.repoUrl)(ctx);
|
|
241
|
+
const options = tags.map((t) => (0, html_1.html) `<option value="${t.name}"${editing && t.name === editing.tag ? (0, html_1.raw)(' selected') : ''}>${t.name}</option>`);
|
|
242
|
+
const del = editing && ctx.canPush
|
|
243
|
+
? (0, html_1.html) `<form method="post" action="${base}/releases/delete" data-confirm="Delete the notes for ${editing.tag}? The tag itself is not touched.">${(0, views_1.csrfField)(viewer)}<input type="hidden" name="tag" value="${editing.tag}"><button type="submit" class="btn btn-danger-outline">${(0, icons_1.icon)('trash')}<span>Delete release</span></button></form>`
|
|
244
|
+
: '';
|
|
245
|
+
const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'releases')}
|
|
246
|
+
<div class="form-box wide">
|
|
247
|
+
<h1>${editing ? 'Edit release' : 'Draft a release'}</h1>
|
|
248
|
+
${error ? (0, html_1.html) `<div class="form-error">${error}</div>` : ''}
|
|
249
|
+
<form method="post" action="${base}/releases/new">
|
|
250
|
+
${(0, views_1.csrfField)(viewer)}
|
|
251
|
+
<div class="field"><label for="tag">Tag</label>
|
|
252
|
+
${editing
|
|
253
|
+
? (0, html_1.html) `<input type="hidden" name="tag" value="${editing.tag}"><p class="mono">${editing.tag}</p>`
|
|
254
|
+
: (0, html_1.html) `<select id="tag" name="tag" required>${options}</select>
|
|
255
|
+
<p class="muted small">Releases attach to a tag that already exists. Create one on the <a href="${base}/tags">tags page</a> first.</p>`}
|
|
256
|
+
</div>
|
|
257
|
+
<div class="field"><label for="name">Title</label>
|
|
258
|
+
<input type="text" id="name" name="name" maxlength="${MAX_NAME}" value="${editing?.name ?? ''}" placeholder="Leave empty to use the tag name"></div>
|
|
259
|
+
<div class="field"><label for="body">Notes</label>
|
|
260
|
+
<textarea class="code-editor" id="body" name="body" rows="16" placeholder="What changed, and what it means for anyone upgrading. Markdown.">${editing?.body ?? ''}</textarea></div>
|
|
261
|
+
<div class="field"><label class="checkbox"><input type="checkbox" name="prerelease" value="1"${editing?.prerelease ? (0, html_1.raw)(' checked') : ''}> This is a pre-release</label></div>
|
|
262
|
+
<div class="actions"><button type="submit" class="btn btn-primary">${editing ? 'Save release' : 'Publish release'}</button><a class="btn" href="${base}/releases">Cancel</a>${del}</div>
|
|
263
|
+
</form>
|
|
264
|
+
</div>`;
|
|
265
|
+
return (0, views_1.layout)(`${editing ? 'Edit' : 'Draft'} release - ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, `${base}/releases/new`));
|
|
266
|
+
}
|
|
267
|
+
// ---- routes ----
|
|
268
|
+
function registerReleases(app, root) {
|
|
269
|
+
// Release notes are prose about a version, not a place to paste an artifact,
|
|
270
|
+
// so this form is held to a much smaller body than the file editor's.
|
|
271
|
+
const form = (0, web_1.urlencodedForm)('256kb');
|
|
272
|
+
// Writing a release is a write to the vault, so it needs the same session,
|
|
273
|
+
// CSRF check, and the write role on the repository that editing a file does.
|
|
274
|
+
function requirePusher(req, res, ctx, viewer) {
|
|
275
|
+
if (!viewer) {
|
|
276
|
+
(0, web_1.fail)(res, 403, 'You must be signed in to do that.', null, '/login');
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
if (!(0, session_1.checkCsrf)(req, viewer)) {
|
|
280
|
+
(0, web_1.fail)(res, 403, 'The form has expired; go back, reload the page, and try again.', viewer);
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
if (!ctx.canPush) {
|
|
284
|
+
(0, web_1.fail)(res, 403, `You do not have push access to ${ctx.collection}/${ctx.repo}.`, viewer, (0, views_1.repoUrl)(ctx));
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
/** Releases with the tag each one points at, newest tag first. */
|
|
290
|
+
function ordered(root, ctx) {
|
|
291
|
+
const byName = new Map(ctx.tags.map((t) => [t.name, t]));
|
|
292
|
+
return listReleases(root, ctx.collection, ctx.repo)
|
|
293
|
+
.map((release) => ({ release, ref: byName.get(release.tag) }))
|
|
294
|
+
.sort((a, b) => {
|
|
295
|
+
const at = a.ref?.date ?? a.release.created;
|
|
296
|
+
const bt = b.ref?.date ?? b.release.created;
|
|
297
|
+
return bt.localeCompare(at);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
app.get('/:collection/:repo/releases', (0, web_1.ah)(async (req, res) => {
|
|
301
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
302
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
303
|
+
if (!loaded)
|
|
304
|
+
return;
|
|
305
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
|
|
306
|
+
const rows = ordered(root, ctx);
|
|
307
|
+
const cards = (0, html_1.joinHtml)(rows.map(({ release, ref }, i) => releaseCard(ctx, release, ref, i === 0 && !release.prerelease, notesHtml(ctx, release))));
|
|
308
|
+
res.type('html').send(releasesPage(ctx, cards, rows.length));
|
|
309
|
+
}));
|
|
310
|
+
app.get('/:collection/:repo/releases.atom', (0, web_1.ah)(async (req, res) => {
|
|
311
|
+
// The viewer is resolved so a signed-in browser can read a private
|
|
312
|
+
// repository's feed; an anonymous feed reader of one gets the 404
|
|
313
|
+
// loadRepo answers.
|
|
314
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, (0, session_1.getViewer)(req, root));
|
|
315
|
+
if (!loaded)
|
|
316
|
+
return;
|
|
317
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', null);
|
|
318
|
+
const base = `${(0, web_1.baseUrlOf)(req)}${(0, views_1.repoUrl)(ctx)}`;
|
|
319
|
+
const rows = ordered(root, ctx);
|
|
320
|
+
res
|
|
321
|
+
.type('application/atom+xml; charset=utf-8')
|
|
322
|
+
.send((0, atom_1.atomFeed)({
|
|
323
|
+
id: `${base}/releases`,
|
|
324
|
+
title: `${ctx.collection}/${ctx.repo} releases`,
|
|
325
|
+
selfLink: `${base}/releases.atom`,
|
|
326
|
+
htmlLink: `${base}/releases`,
|
|
327
|
+
entries: rows.map(({ release, ref }) => ({
|
|
328
|
+
id: `${base}/releases/tag/${(0, views_1.encPath)(release.tag)}`,
|
|
329
|
+
title: releaseTitle(release),
|
|
330
|
+
updated: ref?.date || release.created,
|
|
331
|
+
link: `${base}/releases/tag/${(0, views_1.encPath)(release.tag)}`,
|
|
332
|
+
author: release.author || ctx.collection,
|
|
333
|
+
summary: release.body,
|
|
334
|
+
})),
|
|
335
|
+
}));
|
|
336
|
+
}));
|
|
337
|
+
app.get('/:collection/:repo/releases/new', (0, web_1.ah)(async (req, res) => {
|
|
338
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
339
|
+
if (!viewer) {
|
|
340
|
+
res.redirect(`/login?next=${encodeURIComponent(req.originalUrl)}`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
344
|
+
if (!loaded)
|
|
345
|
+
return;
|
|
346
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
|
|
347
|
+
if (!ctx.canPush) {
|
|
348
|
+
(0, web_1.fail)(res, 403, `You do not have push access to ${ctx.collection}/${ctx.repo}.`, viewer, (0, views_1.repoUrl)(ctx));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const asked = String(req.query.tag ?? '');
|
|
352
|
+
const editing = asked ? readRelease(root, ctx.collection, ctx.repo, asked) : null;
|
|
353
|
+
if (asked && !editing && !ctx.tags.some((t) => t.name === asked)) {
|
|
354
|
+
(0, web_1.send404)(res, `Tag ${asked} not found`, viewer);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// A tag that already has notes is edited rather than drafted twice.
|
|
358
|
+
const draft = editing ??
|
|
359
|
+
(asked
|
|
360
|
+
? { tag: asked, name: '', author: viewer.auth.username, created: '', prerelease: false, body: '' }
|
|
361
|
+
: null);
|
|
362
|
+
res.type('html').send(releaseFormPage(ctx, viewer, ctx.tags, draft));
|
|
363
|
+
}));
|
|
364
|
+
app.post('/:collection/:repo/releases/new', form, (0, web_1.ah)(async (req, res) => {
|
|
365
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
366
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
367
|
+
if (!loaded)
|
|
368
|
+
return;
|
|
369
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
|
|
370
|
+
if (!requirePusher(req, res, ctx, viewer))
|
|
371
|
+
return;
|
|
372
|
+
const tag = (0, web_1.field)(req, 'tag');
|
|
373
|
+
if (!(0, git_1.isValidRefName)(tag) || !ctx.tags.some((t) => t.name === tag)) {
|
|
374
|
+
(0, web_1.fail)(res, 400, `There is no tag named ${tag} in this repository.`, viewer, `${(0, views_1.repoUrl)(ctx)}/releases`);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const existing = readRelease(root, ctx.collection, ctx.repo, tag);
|
|
378
|
+
const body = (0, web_1.field)(req, 'body').replace(/\r\n/g, '\n').slice(0, MAX_NOTES);
|
|
379
|
+
saveRelease(root, ctx.collection, ctx.repo, {
|
|
380
|
+
tag,
|
|
381
|
+
name: (0, web_1.field)(req, 'name').slice(0, MAX_NAME).trim(),
|
|
382
|
+
// The first publisher keeps the byline; an edit does not steal it.
|
|
383
|
+
author: existing?.author || viewer.auth.username,
|
|
384
|
+
created: existing?.created || new Date().toISOString(),
|
|
385
|
+
prerelease: (0, web_1.field)(req, 'prerelease') === '1',
|
|
386
|
+
body,
|
|
387
|
+
});
|
|
388
|
+
res.redirect(`${(0, views_1.repoUrl)(ctx)}/releases/tag/${(0, views_1.encPath)(tag)}`);
|
|
389
|
+
}));
|
|
390
|
+
app.post('/:collection/:repo/releases/delete', form, (0, web_1.ah)(async (req, res) => {
|
|
391
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
392
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
393
|
+
if (!loaded)
|
|
394
|
+
return;
|
|
395
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, loaded.defaultBranch ?? '', viewer);
|
|
396
|
+
if (!requirePusher(req, res, ctx, viewer))
|
|
397
|
+
return;
|
|
398
|
+
deleteRelease(root, ctx.collection, ctx.repo, (0, web_1.field)(req, 'tag'));
|
|
399
|
+
res.redirect(`${(0, views_1.repoUrl)(ctx)}/releases`);
|
|
400
|
+
}));
|
|
401
|
+
app.get('/:collection/:repo/releases/tag/*', (0, web_1.ah)(async (req, res) => {
|
|
402
|
+
const viewer = (0, session_1.getViewer)(req, root);
|
|
403
|
+
const loaded = await (0, web_1.loadRepo)(root, req, res, viewer);
|
|
404
|
+
if (!loaded)
|
|
405
|
+
return;
|
|
406
|
+
const tag = (0, web_1.wildcard)(req);
|
|
407
|
+
const ctx = await (0, web_1.makeCtx)(root, req, loaded, tag, viewer);
|
|
408
|
+
const release = readRelease(root, ctx.collection, ctx.repo, tag);
|
|
409
|
+
if (!release) {
|
|
410
|
+
(0, web_1.send404)(res, `No release for ${tag}`, viewer);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const ref = ctx.tags.find((t) => t.name === tag);
|
|
414
|
+
const rows = ordered(root, ctx);
|
|
415
|
+
const latest = rows.length > 0 && rows[0].release.tag === tag && !release.prerelease;
|
|
416
|
+
const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'releases')}
|
|
417
|
+
<div class="page-head"><h1><a href="${(0, views_1.repoUrl)(ctx)}/releases">Releases</a></h1></div>
|
|
418
|
+
${releaseCard(ctx, release, ref, latest, notesHtml(ctx, release))}`;
|
|
419
|
+
res
|
|
420
|
+
.type('html')
|
|
421
|
+
.send((0, views_1.layout)(`${releaseTitle(release)} - ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, `${(0, views_1.repoUrl)(ctx)}/releases/tag/${(0, views_1.encPath)(tag)}`)));
|
|
422
|
+
}));
|
|
423
|
+
/** Notes are markdown from a repository writer, rendered by the same sanitizing path as a README. */
|
|
424
|
+
function notesHtml(ctx, release) {
|
|
425
|
+
if (release.body.trim() === '')
|
|
426
|
+
return '';
|
|
427
|
+
const base = (0, views_1.repoUrl)(ctx);
|
|
428
|
+
return (0, markdown_1.renderMarkdown)(release.body, {
|
|
429
|
+
rawBase: `${base}/raw/${(0, views_1.encPath)(release.tag)}`,
|
|
430
|
+
blobBase: `${base}/blob/${(0, views_1.encPath)(release.tag)}`,
|
|
431
|
+
issueBase: `${base}/issues`,
|
|
432
|
+
commitBase: `${base}/commit`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
package/dist/render.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.esc = void 0;
|
|
7
|
+
exports.languageFor = languageFor;
|
|
8
|
+
exports.highlightCode = highlightCode;
|
|
9
|
+
exports.highlightedLines = highlightedLines;
|
|
10
|
+
exports.isBinary = isBinary;
|
|
11
|
+
exports.formatSize = formatSize;
|
|
12
|
+
exports.formatDate = formatDate;
|
|
13
|
+
exports.formatDateFull = formatDateFull;
|
|
14
|
+
exports.formatDay = formatDay;
|
|
15
|
+
exports.timeAgo = timeAgo;
|
|
16
|
+
exports.timeTag = timeTag;
|
|
17
|
+
const highlight_js_1 = __importDefault(require("highlight.js"));
|
|
18
|
+
const html_1 = require("./html");
|
|
19
|
+
// esc lives in html.ts now, beside the template tag that made it the
|
|
20
|
+
// exception rather than the rule; re-exported here so the files that still
|
|
21
|
+
// escape by hand keep their import until they are converted.
|
|
22
|
+
var html_2 = require("./html");
|
|
23
|
+
Object.defineProperty(exports, "esc", { enumerable: true, get: function () { return html_2.esc; } });
|
|
24
|
+
const EXT_LANG = {
|
|
25
|
+
js: 'javascript',
|
|
26
|
+
mjs: 'javascript',
|
|
27
|
+
cjs: 'javascript',
|
|
28
|
+
jsx: 'javascript',
|
|
29
|
+
ts: 'typescript',
|
|
30
|
+
tsx: 'typescript',
|
|
31
|
+
py: 'python',
|
|
32
|
+
rb: 'ruby',
|
|
33
|
+
go: 'go',
|
|
34
|
+
rs: 'rust',
|
|
35
|
+
c: 'c',
|
|
36
|
+
h: 'c',
|
|
37
|
+
cpp: 'cpp',
|
|
38
|
+
cc: 'cpp',
|
|
39
|
+
cxx: 'cpp',
|
|
40
|
+
hpp: 'cpp',
|
|
41
|
+
java: 'java',
|
|
42
|
+
json: 'json',
|
|
43
|
+
yml: 'yaml',
|
|
44
|
+
yaml: 'yaml',
|
|
45
|
+
sh: 'bash',
|
|
46
|
+
bash: 'bash',
|
|
47
|
+
zsh: 'bash',
|
|
48
|
+
html: 'xml',
|
|
49
|
+
htm: 'xml',
|
|
50
|
+
xml: 'xml',
|
|
51
|
+
css: 'css',
|
|
52
|
+
scss: 'scss',
|
|
53
|
+
less: 'less',
|
|
54
|
+
md: 'markdown',
|
|
55
|
+
markdown: 'markdown',
|
|
56
|
+
toml: 'ini',
|
|
57
|
+
ini: 'ini',
|
|
58
|
+
cfg: 'ini',
|
|
59
|
+
sql: 'sql',
|
|
60
|
+
php: 'php',
|
|
61
|
+
swift: 'swift',
|
|
62
|
+
kt: 'kotlin',
|
|
63
|
+
m: 'objectivec',
|
|
64
|
+
r: 'r',
|
|
65
|
+
jl: 'julia',
|
|
66
|
+
pl: 'perl',
|
|
67
|
+
lua: 'lua',
|
|
68
|
+
diff: 'diff',
|
|
69
|
+
patch: 'diff',
|
|
70
|
+
txt: 'plaintext',
|
|
71
|
+
};
|
|
72
|
+
const NAME_LANG = {
|
|
73
|
+
dockerfile: 'dockerfile',
|
|
74
|
+
makefile: 'makefile',
|
|
75
|
+
cmakelists_txt: 'cmake',
|
|
76
|
+
};
|
|
77
|
+
function languageFor(filename) {
|
|
78
|
+
const base = filename.split('/').pop() ?? filename;
|
|
79
|
+
const byName = NAME_LANG[base.toLowerCase().replace(/\./g, '_')];
|
|
80
|
+
if (byName)
|
|
81
|
+
return byName;
|
|
82
|
+
const dot = base.lastIndexOf('.');
|
|
83
|
+
if (dot === -1)
|
|
84
|
+
return null;
|
|
85
|
+
return EXT_LANG[base.slice(dot + 1).toLowerCase()] ?? null;
|
|
86
|
+
}
|
|
87
|
+
function highlightCode(text, filename) {
|
|
88
|
+
const lang = languageFor(filename);
|
|
89
|
+
if (lang && highlight_js_1.default.getLanguage(lang)) {
|
|
90
|
+
try {
|
|
91
|
+
return highlight_js_1.default.highlight(text, { language: lang, ignoreIllegals: true }).value;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// fall through to plain
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return (0, html_1.esc)(text);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Highlighted code split into one HTML string per line, so each line can be
|
|
101
|
+
* its own element and so carry an anchor. highlight.js emits spans that cross
|
|
102
|
+
* line boundaries (a block comment, a multi-line string), which is why this
|
|
103
|
+
* cannot be a split on newline: open spans are closed at the end of a line and
|
|
104
|
+
* reopened at the start of the next. The input is highlight.js output, whose
|
|
105
|
+
* only tags are <span class="..."> and </span> and whose text is escaped, so
|
|
106
|
+
* tokenizing on angle brackets is sound here and nowhere else.
|
|
107
|
+
*/
|
|
108
|
+
function highlightedLines(html) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
const open = [];
|
|
111
|
+
let current = '';
|
|
112
|
+
for (const token of html.match(/<[^>]*>|[^<]+/g) ?? []) {
|
|
113
|
+
if (token.startsWith('</')) {
|
|
114
|
+
open.pop();
|
|
115
|
+
current += token;
|
|
116
|
+
}
|
|
117
|
+
else if (token.startsWith('<')) {
|
|
118
|
+
open.push(token);
|
|
119
|
+
current += token;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const parts = token.split('\n');
|
|
123
|
+
parts.forEach((part, i) => {
|
|
124
|
+
if (i > 0) {
|
|
125
|
+
current += '</span>'.repeat(open.length);
|
|
126
|
+
lines.push(current);
|
|
127
|
+
current = open.join('');
|
|
128
|
+
}
|
|
129
|
+
current += part;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
lines.push(current);
|
|
134
|
+
// A file that ends in a newline has no line after it, matching how the line
|
|
135
|
+
// count is taken elsewhere.
|
|
136
|
+
if (lines.length > 1 && lines[lines.length - 1] === '')
|
|
137
|
+
lines.pop();
|
|
138
|
+
return lines;
|
|
139
|
+
}
|
|
140
|
+
function isBinary(buf) {
|
|
141
|
+
const n = Math.min(buf.length, 8192);
|
|
142
|
+
for (let i = 0; i < n; i++) {
|
|
143
|
+
if (buf[i] === 0)
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
// Past a gigabyte the unit keeps going: a release asset or a day's outgoing
|
|
149
|
+
// traffic is routinely larger than that, and "20480.0 MB" is a number a reader
|
|
150
|
+
// has to divide before it means anything.
|
|
151
|
+
function formatSize(bytes) {
|
|
152
|
+
if (bytes < 1024)
|
|
153
|
+
return `${bytes} B`;
|
|
154
|
+
if (bytes < 1024 * 1024)
|
|
155
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
156
|
+
if (bytes < 1024 * 1024 * 1024)
|
|
157
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
158
|
+
if (bytes < 1024 * 1024 * 1024 * 1024)
|
|
159
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
160
|
+
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
|
|
161
|
+
}
|
|
162
|
+
function formatDate(iso) {
|
|
163
|
+
if (!iso)
|
|
164
|
+
return '';
|
|
165
|
+
const d = new Date(iso);
|
|
166
|
+
if (isNaN(d.getTime()))
|
|
167
|
+
return iso;
|
|
168
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
169
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
170
|
+
}
|
|
171
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
172
|
+
/** "19 Aug 2026, 01:04" - the exact time, shown as the tooltip on a relative one. */
|
|
173
|
+
function formatDateFull(iso) {
|
|
174
|
+
const d = new Date(iso);
|
|
175
|
+
if (!iso || isNaN(d.getTime()))
|
|
176
|
+
return iso;
|
|
177
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
178
|
+
return `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}, ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
179
|
+
}
|
|
180
|
+
// Thresholds for timeAgo, coarsest last: below `limit` seconds, divide by
|
|
181
|
+
// `secs` (rounding down, so an age never reads older than it is) and name the
|
|
182
|
+
// unit. A dedicated "yesterday" sits between hours and days because that is
|
|
183
|
+
// what the reader would say.
|
|
184
|
+
const AGO_UNITS = [
|
|
185
|
+
{ limit: 3600, secs: 60, unit: 'minute' },
|
|
186
|
+
{ limit: 24 * 3600, secs: 3600, unit: 'hour' },
|
|
187
|
+
{ limit: 30 * 86400, secs: 86400, unit: 'day' },
|
|
188
|
+
{ limit: 365 * 86400, secs: 30 * 86400, unit: 'month' },
|
|
189
|
+
{ limit: Infinity, secs: 365 * 86400, unit: 'year' },
|
|
190
|
+
];
|
|
191
|
+
/** "19 Aug 2026" - the day alone, for grouping a list by date. */
|
|
192
|
+
function formatDay(iso) {
|
|
193
|
+
const d = new Date(iso);
|
|
194
|
+
if (!iso || isNaN(d.getTime()))
|
|
195
|
+
return iso;
|
|
196
|
+
return `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* A relative time in words: "now", "3 minutes ago", "yesterday", "2 years
|
|
200
|
+
* ago". Dates the clock puts in the future (skew, or a rewritten commit) read
|
|
201
|
+
* as "in 3 minutes" rather than as an absurd age. The exact time is never
|
|
202
|
+
* lost: timeTag() keeps it in the tooltip and in the datetime attribute.
|
|
203
|
+
*/
|
|
204
|
+
function timeAgo(iso, now = new Date()) {
|
|
205
|
+
const d = new Date(iso);
|
|
206
|
+
if (!iso || isNaN(d.getTime()))
|
|
207
|
+
return iso;
|
|
208
|
+
const delta = (now.getTime() - d.getTime()) / 1000;
|
|
209
|
+
const secs = Math.abs(delta);
|
|
210
|
+
const future = delta < 0;
|
|
211
|
+
if (secs < 60)
|
|
212
|
+
return 'now';
|
|
213
|
+
if (!future && secs >= 24 * 3600 && secs < 48 * 3600)
|
|
214
|
+
return 'yesterday';
|
|
215
|
+
const step = AGO_UNITS.find((u) => secs < u.limit);
|
|
216
|
+
const n = Math.floor(secs / step.secs);
|
|
217
|
+
const phrase = `${n} ${step.unit}${n === 1 ? '' : 's'}`;
|
|
218
|
+
return future ? `in ${phrase}` : `${phrase} ago`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A relative time as a <time> element, with the exact time in its tooltip.
|
|
222
|
+
* Rendering is server-side and so is the "now" it counts from, which is why
|
|
223
|
+
* the datetime attribute carries the original timestamp: a reader who wants
|
|
224
|
+
* precision hovers, and a machine reading the page gets ISO 8601.
|
|
225
|
+
*/
|
|
226
|
+
function timeTag(iso, cls = 'muted') {
|
|
227
|
+
if (!iso)
|
|
228
|
+
return (0, html_1.raw)('');
|
|
229
|
+
const d = new Date(iso);
|
|
230
|
+
if (isNaN(d.getTime()))
|
|
231
|
+
return (0, html_1.raw)((0, html_1.esc)(iso));
|
|
232
|
+
return (0, html_1.raw)(`<time class="${cls}" datetime="${(0, html_1.esc)(d.toISOString())}" title="${(0, html_1.esc)(formatDateFull(iso))}">${(0, html_1.esc)(timeAgo(iso))}</time>`);
|
|
233
|
+
}
|