feat: add container manager CLI commands and docker-compose overlay for admin container lifecycle
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
/* retoor <retoor@molodetz.nl> */
|
||||
|
||||
.cm-page { max-width: 1400px; margin: 0 auto; padding: 1rem; }
|
||||
.cm-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
|
||||
.cm-title { font-size: 1.4rem; margin: 0; }
|
||||
.cm-body { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; align-items: start; }
|
||||
.cm-col { background: var(--surface, #1b1f24); border: 1px solid var(--border, #2a2f36); border-radius: 8px; padding: 1rem; }
|
||||
.cm-col-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
|
||||
.cm-col-head h2 { font-size: 1.1rem; margin: 0; }
|
||||
.cm-list { list-style: none; margin: 0; padding: 0; }
|
||||
.cm-item { padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; display: flex; justify-content: space-between; gap: 0.5rem; }
|
||||
.cm-item:hover { background: var(--surface-2, #232830); }
|
||||
.cm-item.active { background: var(--accent-soft, #2d3748); }
|
||||
.cm-detail { margin-top: 1rem; border-top: 1px solid var(--border, #2a2f36); padding-top: 1rem; }
|
||||
.cm-detail-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; flex-wrap: wrap; }
|
||||
.cm-detail-name { font-weight: 600; flex: 1; }
|
||||
.cm-editor { width: 100%; min-height: 260px; font-family: monospace; font-size: 0.85rem; background: #0f1216; color: #d7dde3; border: 1px solid var(--border, #2a2f36); border-radius: 6px; padding: 0.5rem; resize: vertical; }
|
||||
.cm-builds { margin-top: 1rem; }
|
||||
.cm-builds h3 { font-size: 0.95rem; margin: 0 0 0.5rem; }
|
||||
.cm-log { background: #0f1216; color: #cfe3cf; font-family: monospace; font-size: 0.8rem; padding: 0.75rem; border-radius: 6px; max-height: 320px; overflow: auto; white-space: pre-wrap; margin-top: 0.75rem; }
|
||||
.cm-actions { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.75rem; }
|
||||
.cm-metrics { font-family: monospace; font-size: 0.8rem; color: var(--muted, #9aa4b2); margin-bottom: 0.5rem; }
|
||||
.cm-exec { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.cm-exec input { flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border, #2a2f36); background: #0f1216; color: #d7dde3; font-family: monospace; }
|
||||
.cm-badge { font-size: 0.7rem; padding: 0.1rem 0.45rem; border-radius: 10px; background: #2a2f36; text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.cm-badge.cm-running { background: #1f7a3d; color: #fff; }
|
||||
.cm-badge.cm-crashed { background: #8a2b2b; color: #fff; }
|
||||
.cm-badge.cm-stopped { background: #4a5160; color: #fff; }
|
||||
.cm-badge.cm-paused { background: #8a6d1f; color: #fff; }
|
||||
|
||||
@media (max-width: 900px) { .cm-body { grid-template-columns: 1fr; } }
|
||||
@@ -224,3 +224,13 @@
|
||||
width: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.pf-readonly-banner {
|
||||
margin: 0.75rem 0;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-muted);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
export class ContainerManager {
|
||||
constructor(root) {
|
||||
this.root = root;
|
||||
this.slug = root.dataset.slug;
|
||||
this.base = `/projects/${this.slug}/containers`;
|
||||
this.dockerfiles = [];
|
||||
this.instances = [];
|
||||
this.selectedDockerfile = null;
|
||||
this.pollMs = 3000;
|
||||
this.buildPoll = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
const data = JSON.parse(this.root.querySelector("#cm-data").textContent || "{}");
|
||||
this.dockerfiles = data.dockerfiles || [];
|
||||
this.instances = data.instances || [];
|
||||
this.bind();
|
||||
this.renderDockerfiles();
|
||||
this.renderInstances();
|
||||
this.fillDockerfileSelect();
|
||||
setInterval(() => this.refresh(), this.pollMs);
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.q("#cm-save-version").addEventListener("click", () => this.saveVersion());
|
||||
this.q("#cm-rebuild").addEventListener("click", () => this.rebuild());
|
||||
const dfForm = document.getElementById("cm-new-dockerfile-form");
|
||||
if (dfForm) dfForm.addEventListener("submit", (e) => { e.preventDefault(); this.createDockerfile(dfForm); });
|
||||
const instForm = document.getElementById("cm-new-instance-form");
|
||||
if (instForm) instForm.addEventListener("submit", (e) => { e.preventDefault(); this.createInstance(instForm); });
|
||||
}
|
||||
|
||||
q(sel) { return this.root.querySelector(sel); }
|
||||
|
||||
closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
if (modal) modal.classList.remove("visible");
|
||||
}
|
||||
|
||||
async getJson(url) {
|
||||
const res = await fetch(url, { headers: { "Accept": "application/json" } });
|
||||
if (!res.ok) throw new Error(`request failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async post(url, body) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(body || {}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data.error && data.error.message) || `request failed: ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
toast(message, type) {
|
||||
if (window.app && window.app.toast) window.app.toast.show(message, { type: type || "info" });
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
const data = await this.getJson(`${this.base}/data`);
|
||||
this.dockerfiles = data.dockerfiles || [];
|
||||
this.instances = data.instances || [];
|
||||
this.renderDockerfiles();
|
||||
this.renderInstances();
|
||||
this.fillDockerfileSelect();
|
||||
} catch { /* ignore poll errors */ }
|
||||
}
|
||||
|
||||
// ---------------- dockerfiles ----------------
|
||||
|
||||
renderDockerfiles() {
|
||||
const list = this.q("#cm-dockerfile-list");
|
||||
list.innerHTML = "";
|
||||
for (const df of this.dockerfiles) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "cm-item" + (this.selectedDockerfile === df.uid ? " active" : "");
|
||||
li.textContent = `${df.name} (v${df.current_version}, build ${df.latest_build_number})`;
|
||||
li.addEventListener("click", () => this.openDockerfile(df.uid));
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
fillDockerfileSelect() {
|
||||
const select = document.getElementById("cm-inst-new-df");
|
||||
if (!select) return;
|
||||
const current = select.value;
|
||||
select.innerHTML = "";
|
||||
for (const df of this.dockerfiles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = df.uid;
|
||||
option.textContent = df.name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
if (current) select.value = current;
|
||||
else if (this.selectedDockerfile) select.value = this.selectedDockerfile;
|
||||
}
|
||||
|
||||
async createDockerfile(form) {
|
||||
const name = form.elements.name.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await this.post(`${this.base}/dockerfiles`, {
|
||||
name, description: form.elements.description.value.trim(), tags: "",
|
||||
});
|
||||
this.closeModal("cm-new-dockerfile-modal");
|
||||
form.reset();
|
||||
this.toast("Dockerfile created, build queued", "success");
|
||||
await this.refresh();
|
||||
if (res.data && res.data.dockerfile) this.openDockerfile(res.data.dockerfile.uid);
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
async openDockerfile(uid) {
|
||||
this.selectedDockerfile = uid;
|
||||
try {
|
||||
const detail = await this.getJson(`${this.base}/dockerfiles/${uid}`);
|
||||
this.q("#cm-dockerfile-detail").hidden = false;
|
||||
this.q("#cm-df-name").textContent = detail.dockerfile.name;
|
||||
this.q("#cm-df-editor").value = detail.content || "";
|
||||
this.renderBuilds(detail.builds || []);
|
||||
this.renderDockerfiles();
|
||||
this.fillDockerfileSelect();
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
renderBuilds(builds) {
|
||||
const list = this.q("#cm-build-list");
|
||||
list.innerHTML = "";
|
||||
for (const b of builds) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "cm-item";
|
||||
li.textContent = `${b.image_tag} [${b.status}]`;
|
||||
li.addEventListener("click", () => this.watchBuild(b.uid));
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async saveVersion() {
|
||||
if (!this.selectedDockerfile) return;
|
||||
const content = this.q("#cm-df-editor").value;
|
||||
try {
|
||||
const res = await this.post(`${this.base}/dockerfiles/${this.selectedDockerfile}/version`, { content });
|
||||
if (res.data && res.data.changed) {
|
||||
this.toast("New version saved, build queued", "success");
|
||||
if (res.data.build_uid) this.watchBuild(res.data.build_uid);
|
||||
} else {
|
||||
this.toast("No change in content", "info");
|
||||
}
|
||||
await this.refresh();
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
async rebuild() {
|
||||
if (!this.selectedDockerfile) return;
|
||||
try {
|
||||
const res = await this.post(`${this.base}/dockerfiles/${this.selectedDockerfile}/build`, {});
|
||||
this.toast("Rebuild queued", "success");
|
||||
if (res.data && res.data.build_uid) this.watchBuild(res.data.build_uid);
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
|
||||
watchBuild(uid) {
|
||||
if (this.buildPoll) clearInterval(this.buildPoll);
|
||||
const pre = this.q("#cm-build-log");
|
||||
pre.hidden = false;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const build = await this.getJson(`${this.base}/builds/${uid}`);
|
||||
pre.textContent = `[${build.status}] ${build.image_tag}\n\n${build.logs || ""}${build.error || ""}`;
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
if (["success", "failed", "cancelled"].includes(build.status)) {
|
||||
clearInterval(this.buildPoll);
|
||||
this.buildPoll = null;
|
||||
this.refresh();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
tick();
|
||||
this.buildPoll = setInterval(tick, 1500);
|
||||
}
|
||||
|
||||
// ---------------- instances ----------------
|
||||
|
||||
renderInstances() {
|
||||
const list = this.q("#cm-instance-list");
|
||||
list.innerHTML = "";
|
||||
for (const inst of this.instances) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "cm-item";
|
||||
const link = document.createElement("a");
|
||||
link.className = "cm-item-link";
|
||||
link.href = `/admin/containers/${inst.uid}`;
|
||||
link.innerHTML = `<span>${inst.name}</span> <span class="cm-badge cm-${inst.status}">${inst.status}</span>`;
|
||||
li.appendChild(link);
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async createInstance(form) {
|
||||
if (!this.dockerfiles.length) { this.toast("Create a Dockerfile first", "error"); return; }
|
||||
const name = form.elements.name.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await this.post(`${this.base}/instances`, {
|
||||
dockerfile_uid: form.elements.dockerfile_uid.value,
|
||||
name,
|
||||
boot_command: form.elements.boot_command.value.trim(),
|
||||
restart_policy: form.elements.restart_policy.value,
|
||||
ports: form.elements.ports.value.trim(),
|
||||
ingress_slug: form.elements.ingress_slug.value.trim(),
|
||||
ingress_port: form.elements.ingress_port.value.trim(),
|
||||
autostart: form.elements.autostart.checked ? "true" : "false",
|
||||
});
|
||||
this.closeModal("cm-new-instance-modal");
|
||||
form.reset();
|
||||
this.toast("Instance created", "success");
|
||||
await this.refresh();
|
||||
} catch (err) { this.toast(err.message, "error"); }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import DeviiClient from "./DeviiClient.js";
|
||||
|
||||
const CSS_ID = "devii-terminal-css";
|
||||
const STORAGE_KEY = "devii-terminal-state";
|
||||
const HISTORY_KEY = "devii-terminal-history";
|
||||
const HISTORY_LIMIT = 500;
|
||||
const MOBILE_QUERY = "(max-width: 640px)";
|
||||
const TRACE_GLYPHS = {
|
||||
call: "↳",
|
||||
@@ -16,12 +18,14 @@ const TRACE_GLYPHS = {
|
||||
"verify-gate": "verification gate",
|
||||
compact: "context compaction",
|
||||
};
|
||||
const CLEAR_WORDS = new Set(["clear", "reset", "/clear", "/reset"]);
|
||||
const CLEAR_WORDS = new Set(["clear", "/clear"]);
|
||||
const RESET_WORDS = new Set(["reset", "/reset"]);
|
||||
const CLOSE_WORDS = new Set(["exit", "close", "quit", "bye", "/exit", "/close", "/quit", "/bye"]);
|
||||
const HELP = [
|
||||
"Local commands:",
|
||||
" /help show this help",
|
||||
" clear | reset clear the conversation",
|
||||
" clear clear the conversation",
|
||||
" reset cancel the running task and clear the conversation",
|
||||
" exit | close | quit | bye close the terminal",
|
||||
" /show show devii (the avatar)",
|
||||
" /hide hide devii",
|
||||
@@ -38,6 +42,8 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
this.socket = null;
|
||||
this.history = [];
|
||||
this.historyIndex = 0;
|
||||
this.draft = "";
|
||||
this._historyKey = null;
|
||||
this.state = "closed";
|
||||
this.geometry = null;
|
||||
this._drag = null;
|
||||
@@ -57,7 +63,10 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
window.addEventListener("pagehide", () => this._captureFocusForUnload());
|
||||
window.addEventListener("beforeunload", () => this._captureFocusForUnload());
|
||||
this._identity = null;
|
||||
this._fetchIdentity().then((id) => { this._identity = id; });
|
||||
this._fetchIdentity().then((id) => {
|
||||
this._identity = id;
|
||||
this._initHistory(id);
|
||||
});
|
||||
window.addEventListener("focus", () => {
|
||||
this._checkIdentity();
|
||||
this._reportVisibility();
|
||||
@@ -147,6 +156,40 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
_historyKeyFor(identity) {
|
||||
const scope = identity && identity.loggedIn && identity.username
|
||||
? `user:${identity.username}`
|
||||
: "guest";
|
||||
return `${HISTORY_KEY}:${scope}`;
|
||||
}
|
||||
|
||||
_initHistory(identity) {
|
||||
this._historyKey = this._historyKeyFor(identity);
|
||||
this.history = this._loadHistory();
|
||||
this.historyIndex = this.history.length;
|
||||
this.draft = "";
|
||||
}
|
||||
|
||||
_loadHistory() {
|
||||
if (!this._historyKey) return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(this._historyKey);
|
||||
const parsed = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
_saveHistory() {
|
||||
if (!this._historyKey) return;
|
||||
try {
|
||||
window.localStorage.setItem(this._historyKey, JSON.stringify(this.history));
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_ensureConnected() {
|
||||
if (this._connected) return;
|
||||
this._connected = true;
|
||||
@@ -360,10 +403,10 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
const text = this.input.value;
|
||||
this.input.value = "";
|
||||
this._submit(text);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
} else if (event.key === "ArrowUp" || event.key === "PageUp") {
|
||||
event.preventDefault();
|
||||
this._recall(-1);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
} else if (event.key === "ArrowDown" || event.key === "PageDown") {
|
||||
event.preventDefault();
|
||||
this._recall(1);
|
||||
} else if (event.key === "Escape") {
|
||||
@@ -373,20 +416,38 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
|
||||
_recall(direction) {
|
||||
if (!this.history.length) return;
|
||||
this.historyIndex = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
|
||||
this.input.value = this.history[this.historyIndex] || "";
|
||||
if (this.historyIndex === this.history.length) {
|
||||
this.draft = this.input.value;
|
||||
}
|
||||
const target = Math.max(0, Math.min(this.history.length, this.historyIndex + direction));
|
||||
if (target === this.historyIndex) return;
|
||||
this.historyIndex = target;
|
||||
const value = target === this.history.length ? this.draft : this.history[target];
|
||||
this.input.value = value;
|
||||
this.input.setSelectionRange(value.length, value.length);
|
||||
}
|
||||
|
||||
_submit(raw) {
|
||||
const text = (raw || "").trim();
|
||||
if (!text) return;
|
||||
this.history.push(text);
|
||||
if (this.history[this.history.length - 1] !== text) {
|
||||
this.history.push(text);
|
||||
if (this.history.length > HISTORY_LIMIT) {
|
||||
this.history = this.history.slice(-HISTORY_LIMIT);
|
||||
}
|
||||
this._saveHistory();
|
||||
}
|
||||
this.historyIndex = this.history.length;
|
||||
this.draft = "";
|
||||
const lowered = text.toLowerCase();
|
||||
if (CLEAR_WORDS.has(lowered)) {
|
||||
if (RESET_WORDS.has(lowered)) {
|
||||
this._resetConversation();
|
||||
return;
|
||||
}
|
||||
if (CLEAR_WORDS.has(lowered)) {
|
||||
this._clearConversation();
|
||||
return;
|
||||
}
|
||||
if (CLOSE_WORDS.has(lowered)) {
|
||||
this.close();
|
||||
return;
|
||||
@@ -406,6 +467,11 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
this.socket.send({ type: "reset" });
|
||||
}
|
||||
|
||||
_clearConversation() {
|
||||
this.output.innerHTML = "";
|
||||
this.socket.send({ type: "clear" });
|
||||
}
|
||||
|
||||
async _local(text) {
|
||||
const [command, ...rest] = text.slice(1).split(" ");
|
||||
const argument = rest.join(" ").trim();
|
||||
@@ -468,6 +534,9 @@ export default class DeviiTerminalElement extends HTMLElement {
|
||||
try {
|
||||
const response = await fetch("/devii/session", { credentials: "same-origin" });
|
||||
const data = await response.json();
|
||||
if (data.baseUrl) {
|
||||
this.markdown.baseUrl = data.baseUrl;
|
||||
}
|
||||
return { loggedIn: !!data.loggedIn, username: data.username || "" };
|
||||
} catch (error) {
|
||||
return null;
|
||||
|
||||
@@ -7,10 +7,13 @@ const SAFE_HREF = /^(https?:\/\/|\/|#|mailto:)/i;
|
||||
export default class Markdown {
|
||||
constructor() {
|
||||
this.highlighter = new Highlighter();
|
||||
this.baseUrl = "";
|
||||
}
|
||||
|
||||
escape(text) {
|
||||
return text
|
||||
return String(text)
|
||||
.split(String.fromCharCode(0))
|
||||
.join("")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
@@ -22,23 +25,67 @@ export default class Markdown {
|
||||
const tick = String.fromCharCode(96);
|
||||
const codeRe = new RegExp(tick + "([^" + tick + "]+)" + tick, "g");
|
||||
let out = this.escape(text);
|
||||
out = out.replace(codeRe, (_m, code) => `<code class="md-code">${code}</code>`);
|
||||
const tokens = [];
|
||||
const stash = (html) => {
|
||||
const key = String.fromCharCode(0) + tokens.length + String.fromCharCode(0);
|
||||
tokens.push(html);
|
||||
return key;
|
||||
};
|
||||
out = out.replace(codeRe, (_m, code) => stash(`<code class="md-code">${code}</code>`));
|
||||
const imgRe = new RegExp("!\\[([^\\]]*)\\]\\(([^)\\s]+)\\)", "g");
|
||||
out = out.replace(imgRe, (_m, alt, src) => this._image(alt, src));
|
||||
out = out.replace(imgRe, (_m, alt, src) => stash(this._image(alt, src)));
|
||||
const linkRe = new RegExp("\\[([^\\]]+)\\]\\(([^)\\s]+)\\)", "g");
|
||||
out = out.replace(linkRe, (_m, label, href) => this._link(label, href));
|
||||
out = out.replace(linkRe, (_m, label, href) => stash(this._link(label, href)));
|
||||
out = this._autolink(out, stash);
|
||||
out = out.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
out = out.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
||||
out = out.replace(/(^|[^*])\*([^*\s][^*]*?)\*/g, "$1<em>$2</em>");
|
||||
out = out.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
||||
out = out.replace(new RegExp(String.fromCharCode(0) + "(\\d+)" + String.fromCharCode(0), "g"), (_m, i) => tokens[Number(i)]);
|
||||
return out;
|
||||
}
|
||||
|
||||
_resolveHref(href) {
|
||||
if (/^(https?:\/\/|mailto:|#)/i.test(href)) {
|
||||
return href;
|
||||
}
|
||||
if (href.startsWith("/") && !href.startsWith("//")) {
|
||||
const base = (this.baseUrl || (typeof globalThis !== "undefined" && globalThis.location ? globalThis.location.origin : "")).replace(/\/+$/, "");
|
||||
return base + href;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_autolink(text, stash) {
|
||||
const NUL = String.fromCharCode(0);
|
||||
const absRe = new RegExp("(^|[\\s(])(https?:\\/\\/[^\\s<)" + NUL + "]+)", "g");
|
||||
text = text.replace(absRe, (_m, pre, url) => pre + this._autoAnchor(url, stash));
|
||||
const relRe = new RegExp("(^|[\\s(])(\\/[A-Za-z0-9._~\\-][A-Za-z0-9._~\\-\\/%?=&;#:+@]*)", "g");
|
||||
text = text.replace(relRe, (_m, pre, url) => pre + this._autoAnchor(url, stash));
|
||||
return text;
|
||||
}
|
||||
|
||||
_autoAnchor(rawUrl, stash) {
|
||||
let url = rawUrl;
|
||||
let trail = "";
|
||||
const match = url.match(/[.,;:!?)\]}'"]+$/);
|
||||
if (match) {
|
||||
trail = match[0];
|
||||
url = url.slice(0, url.length - trail.length);
|
||||
}
|
||||
const href = url ? this._resolveHref(url) : null;
|
||||
if (!href) {
|
||||
return rawUrl;
|
||||
}
|
||||
return stash(`<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`) + trail;
|
||||
}
|
||||
|
||||
_link(label, href) {
|
||||
if (!SAFE_HREF.test(href)) {
|
||||
const resolved = this._resolveHref(href);
|
||||
if (!resolved || !SAFE_HREF.test(resolved)) {
|
||||
return this.escape(label);
|
||||
}
|
||||
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
return `<a href="${resolved}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
}
|
||||
|
||||
_image(alt, src) {
|
||||
|
||||
Reference in New Issue
Block a user