mirror of https://github.com/tycrek/ass
commit
c6a5007939
@ -0,0 +1,32 @@
|
||||
import { AssFile, AssUser, EmbedTemplate, PreparedEmbed } from 'ass'
|
||||
|
||||
import { TemplateExecutor } from './templates/executor.js';
|
||||
|
||||
let executor = TemplateExecutor.createExecutor();
|
||||
|
||||
export const DEFAULT_EMBED: EmbedTemplate = {
|
||||
sitename: 'ass',
|
||||
title: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
// ensures a template is valid
|
||||
export const validateEmbed = (template: EmbedTemplate) => {
|
||||
// lets hope this works
|
||||
let context = executor.createContext(null!, null!);
|
||||
|
||||
executor.validateTemplate(template.title, context);
|
||||
executor.validateTemplate(template.description, context);
|
||||
executor.validateTemplate(template.sitename, context);
|
||||
}
|
||||
|
||||
// cooks up the embed
|
||||
export const prepareEmbed = (template: EmbedTemplate, user: AssUser, file: AssFile): PreparedEmbed => {
|
||||
let context = executor.createContext(user, file);
|
||||
|
||||
return {
|
||||
title: executor.executeTemplate(template.title, context),
|
||||
description: executor.executeTemplate(template.description, context),
|
||||
sitename: executor.executeTemplate(template.sitename, context)
|
||||
};
|
||||
};
|
@ -0,0 +1,10 @@
|
||||
import { TemplateCommandOp, TemplateCommandSchema } from 'ass';
|
||||
|
||||
import { TemplateContext } from './executor.js';
|
||||
|
||||
export type TemplateCommand<N extends string, S extends TemplateCommandSchema> = {
|
||||
readonly name: N;
|
||||
readonly schema: S;
|
||||
|
||||
exec: (op: TemplateCommandOp<N, S>, ctx: TemplateContext) => string;
|
||||
};
|
@ -0,0 +1,81 @@
|
||||
import { TemplateSourceRange } from 'ass';
|
||||
|
||||
export class TemplateError extends Error {
|
||||
range?: TemplateSourceRange;
|
||||
|
||||
constructor(msg: string, range?: TemplateSourceRange) {
|
||||
super(msg);
|
||||
|
||||
this.range = range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the error.
|
||||
*/
|
||||
public format(): string {
|
||||
let format = '';
|
||||
|
||||
if (this.range) {
|
||||
let fcol = 0;
|
||||
let fline = 1;
|
||||
let pstart = 0;
|
||||
|
||||
for (let i = 0; i < this.range.from; i++) {
|
||||
fcol++;
|
||||
if (this.range.file.code[i] == '\n') {
|
||||
fline++;
|
||||
fcol = 0;
|
||||
pstart = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
let tcol = fcol;
|
||||
let tline = fline;
|
||||
let pend = pstart;
|
||||
|
||||
for (let i = this.range.from; i < this.range.to; i++) {
|
||||
tcol++;
|
||||
if (this.range.file.code[i] == '\n') {
|
||||
tline++;
|
||||
tcol = 0;
|
||||
pend = i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ((pend = this.range.file.code.indexOf('\n', pend)) == -1) {
|
||||
pend = this.range.file.code.length - 1;
|
||||
}
|
||||
|
||||
if (fline == tline) {
|
||||
format += `${fline.toString().padStart(5, ' ')} | ${this.range.file.code.substring(pstart, pend + 1)}\n`;
|
||||
format += `${fline.toString().padStart(5, ' ')} | ${' '.repeat(fcol)}^${'~'.repeat(Math.max(tcol - fcol, 0))}\n`;
|
||||
} else {
|
||||
let lines = this.range.file.code.substring(pstart, pend + 1).split('\n');
|
||||
|
||||
format += ` | /${'~'.repeat(lines[0].length)}v\n`;
|
||||
|
||||
for (let i = fline; i < fline + 5 && i <= tline; i++) {
|
||||
format += `${i.toString().padStart(5, ' ')} | | ${lines[i - fline]}\n`;
|
||||
}
|
||||
|
||||
if (fline + 5 < tline) {
|
||||
format += ` | | ...\n`;
|
||||
|
||||
for (let i = tline - 4; i <= tline; i++) {
|
||||
format += `${i.toString().padStart(5, ' ')} | | ${lines[i - fline]}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
format += ` | \\${'~'.repeat(tcol + 1)}^\n`;
|
||||
}
|
||||
}
|
||||
|
||||
format += `${this.name}: ${this.message}`;
|
||||
|
||||
return format;
|
||||
}
|
||||
}
|
||||
|
||||
// template syntax error with token range, token range converted to source position
|
||||
// outside of prepareTemplate
|
||||
export class TemplateSyntaxError extends TemplateError {};
|
@ -0,0 +1,122 @@
|
||||
import { AssFile, AssUser, TemplateCommandOp, TemplateCommandSchema, TemplateOp } from 'ass';
|
||||
|
||||
import { TemplateCommand } from './command.js';
|
||||
import { TemplateError } from './error.js';
|
||||
|
||||
export class TemplateContext {
|
||||
public readonly owner: TemplateExecutor;
|
||||
|
||||
public uploader: AssUser;
|
||||
public file: AssFile;
|
||||
|
||||
constructor(owner: TemplateExecutor, uploader: AssUser, file: AssFile) {
|
||||
this.owner = owner;
|
||||
this.uploader = uploader;
|
||||
this.file = file;
|
||||
}
|
||||
}
|
||||
|
||||
export class TemplateExecutor {
|
||||
private commands: { [index: string]: TemplateCommand<any, any> } = {};
|
||||
|
||||
// register a template command globally
|
||||
public registerCommand<N extends string, S extends TemplateCommandSchema>(name: N, attrs: S, cmd: (op: TemplateCommandOp<N, S>, ctx: TemplateContext) => string) {
|
||||
if (this.commands[name] == null) {
|
||||
this.commands[name] = {
|
||||
name: name,
|
||||
schema: attrs,
|
||||
exec: cmd
|
||||
};
|
||||
} else throw new Error(`Template command "${name}" already exists`);
|
||||
}
|
||||
|
||||
public createContext(uploader: AssUser, file: AssFile) {
|
||||
return new TemplateContext(this, uploader, file);
|
||||
}
|
||||
|
||||
// expects template to be valid and does not preform runtime checks.
|
||||
// run validateTemplate first
|
||||
public executeTemplate(op: TemplateOp, ctx: TemplateContext): string {
|
||||
switch (typeof op) {
|
||||
case 'string': return op;
|
||||
case 'object': return this.commands[op.op].exec(op, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
public validateTemplate(op: TemplateOp, ctx: TemplateContext): void {
|
||||
if (typeof op == 'string') return;
|
||||
|
||||
if (this.commands[op.op] != null) {
|
||||
let cmd = this.commands[op.op].schema as TemplateCommandSchema;
|
||||
|
||||
if (cmd.named) {
|
||||
for (let name in cmd.named) {
|
||||
let arg = cmd.named[name];
|
||||
|
||||
// @ts-ignore
|
||||
if (arg.required && op.named[name] == null) {
|
||||
throw new TemplateError(`Required template argument "${name}" is missing.`, op.srcRange);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let arg of op.args) this.validateTemplate(arg, ctx);
|
||||
for (let name in op.named) {
|
||||
if (!cmd.named || cmd.named[name] == null) {
|
||||
let arg = (op.named as {[index:string]:TemplateOp})[name] as TemplateOp | undefined;
|
||||
|
||||
// @ts-ignore
|
||||
throw new TemplateError(`Unknown template argument "${name}".`, {
|
||||
file: op.srcRange.file,
|
||||
from: (typeof arg == 'object' && arg.srcRange.from - 1 - name.length) || op.srcRange.from,
|
||||
to: (typeof arg == 'object' && arg.srcRange.to) || op.srcRange.to
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
this.validateTemplate(op.named[name]!, ctx);
|
||||
}
|
||||
} else throw new TemplateError(`Template command "${op.op}" does not exist.`, op.srcRange);
|
||||
}
|
||||
|
||||
// creates an executor with the default commands.
|
||||
public static createExecutor(): TemplateExecutor {
|
||||
let ex = new TemplateExecutor();
|
||||
|
||||
// joins two strings
|
||||
ex.registerCommand('concat', {}, (op, ctx) => {
|
||||
return op.args.reduce((a: string, b): string => a + ctx.owner.executeTemplate(b, ctx), "");
|
||||
});
|
||||
|
||||
// converts a number to a file size
|
||||
ex.registerCommand('formatbytes', {
|
||||
named: { unit: {} }
|
||||
}, (op, ctx) => {
|
||||
let value = ctx.owner.executeTemplate(op.args[0], ctx);
|
||||
|
||||
let exponent = (op.named.unit != null && { 'b': 0, 'kb': 1, 'mb': 2, 'gb': 3, 'tb': 4 }[ctx.owner.executeTemplate(op.named.unit, ctx)])
|
||||
|| Math.max(Math.min(Math.floor(Math.log10(Number(value))/3), 4), 0);
|
||||
|
||||
return `${(Number(value) / 1000 ** exponent).toFixed(2)}${['b', 'kb', 'mb', 'gb', 'tb'][exponent]}`;
|
||||
});
|
||||
|
||||
// gets the size of the active file
|
||||
ex.registerCommand('filesize', {}, (op, ctx) => {
|
||||
return ctx.file.size.toString();
|
||||
});
|
||||
|
||||
// gets the uploader of the active file
|
||||
ex.registerCommand('uploader', {}, (op, ctx) => {
|
||||
return ctx.uploader.username;
|
||||
});
|
||||
|
||||
// selects a random argument
|
||||
ex.registerCommand('random', {}, (op, ctx) => {
|
||||
if (op.args.length > 0) {
|
||||
return ctx.owner.executeTemplate(op.args[Math.round(Math.random() * (op.args.length - 1))], ctx);
|
||||
} else throw new TemplateError('Random without arguments');
|
||||
});
|
||||
|
||||
return ex;
|
||||
}
|
||||
}
|
@ -0,0 +1,305 @@
|
||||
import { TemplateOp, TemplateSource } from 'ass';
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { TemplateSyntaxError } from './error.js';
|
||||
|
||||
enum TokenType {
|
||||
T_OPEN, T_CLOSE,
|
||||
PIPE, EQUALS,
|
||||
TEXT,
|
||||
};
|
||||
|
||||
type TemplateToken = {
|
||||
type : TokenType;
|
||||
data?: string;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
// tree used by findReplacement to select the best amp-substitution
|
||||
type TemplateAmpNode = { [index: string]: TemplateAmpNode | string | undefined; }
|
||||
const TEMPLATE_AMP_SUBSTITUTIONS: TemplateAmpNode = {
|
||||
e: { q: { $: '=' } },
|
||||
p: { i: { p: { e: { $: '|' } } } },
|
||||
t: {
|
||||
c: { l: { o: { s: { e: { $: '}}' } } } } },
|
||||
o: { p: { e: { n: { $: '{{' } } } }
|
||||
}
|
||||
};
|
||||
|
||||
function getTemplateTokens(src: string): TemplateToken[] {
|
||||
let tokens: TemplateToken[] = [];
|
||||
let buf : string = '';
|
||||
let pos : number = 0;
|
||||
|
||||
// digs through TEMPLATE_AMP_SUBSTITUTIONS to find
|
||||
// longest possible string to replace
|
||||
function findReplacement() {
|
||||
let raw = "";
|
||||
let bestpos: number | null = null;
|
||||
let best: string | null = null;
|
||||
let node = TEMPLATE_AMP_SUBSTITUTIONS;
|
||||
|
||||
while (true) {
|
||||
if (pos >= src.length) break;
|
||||
if (!/[a-z]/.test(src[pos])) break;
|
||||
|
||||
if (node[src[pos]] != null) { // enter the thing
|
||||
node = node[src[pos]] as TemplateAmpNode;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (node.$ != null) {
|
||||
best = node.$ as string;
|
||||
bestpos = pos;
|
||||
}
|
||||
|
||||
raw += src[pos++];
|
||||
}
|
||||
|
||||
if (best != null) {
|
||||
pos = bestpos! + 1;
|
||||
return best;
|
||||
}
|
||||
|
||||
return `&${raw}`;
|
||||
}
|
||||
|
||||
for (; pos < src.length; pos++) {
|
||||
let lp = pos;
|
||||
|
||||
if (pos + 1 < src.length && src[pos] == '{' && src[pos + 1] == '{') {
|
||||
tokens.push({
|
||||
type: TokenType.T_OPEN,
|
||||
from: pos,
|
||||
to: pos + 1
|
||||
});
|
||||
pos++;
|
||||
} else if (pos + 1 < src.length && src[pos] == '}' && src[pos + 1] == '}') {
|
||||
tokens.push({
|
||||
type: TokenType.T_CLOSE,
|
||||
from: pos,
|
||||
to: pos + 1
|
||||
});
|
||||
pos++;
|
||||
} else if (src[pos] == '|') {
|
||||
tokens.push({
|
||||
type: TokenType.PIPE,
|
||||
from: pos,
|
||||
to: pos
|
||||
});
|
||||
} else if (src[pos] == '=') {
|
||||
tokens.push({
|
||||
type: TokenType.EQUALS,
|
||||
from: pos,
|
||||
to: pos
|
||||
});
|
||||
} else if (src[pos] == '&') {
|
||||
pos++;
|
||||
buf += findReplacement();
|
||||
pos--; continue;
|
||||
} else if (src[pos] == '\n') {
|
||||
pos++;
|
||||
for (; pos < src.length && (src[pos] == ' ' || src[pos] == '\t'); pos++);
|
||||
pos--; continue;
|
||||
} else {
|
||||
buf += src[pos];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (buf.length) {
|
||||
tokens.splice(-1, 0, {
|
||||
type: TokenType.TEXT,
|
||||
data: buf,
|
||||
from: lp - buf.length,
|
||||
to: lp - 1
|
||||
});
|
||||
buf = '';
|
||||
}
|
||||
}
|
||||
|
||||
if (buf.length) tokens.push({
|
||||
type: TokenType.TEXT,
|
||||
data: buf,
|
||||
from: src.length - buf.length,
|
||||
to: src.length
|
||||
});
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export type PrepareTemplateOptions = {
|
||||
allowIncludeFile?: boolean;
|
||||
};
|
||||
|
||||
export function prepareTemplate(src: string, config?: PrepareTemplateOptions): TemplateOp {
|
||||
let options = {
|
||||
includeFiles: config?.allowIncludeFile ?? false
|
||||
};
|
||||
|
||||
type ParserStackEntry = {
|
||||
pos: number
|
||||
};
|
||||
|
||||
let tokens = getTemplateTokens(src);
|
||||
let pos = 0;
|
||||
|
||||
let stack: ParserStackEntry[] = [];
|
||||
|
||||
function stackPush() {
|
||||
stack.push({ pos: pos });
|
||||
}
|
||||
|
||||
function stackDrop() {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
let file: TemplateSource = { code: src };
|
||||
|
||||
// parse the text part of stuff. like uh
|
||||
// you know uh like uh this part
|
||||
// V---V V V-V V
|
||||
// Hello {Concat|W|ORL|D}
|
||||
function parseConcat(root: boolean = false): TemplateOp {
|
||||
let joined: TemplateOp[] = [];
|
||||
|
||||
let start = pos;
|
||||
|
||||
stackPush();
|
||||
|
||||
out: while (pos < tokens.length) {
|
||||
switch (tokens[pos].type) {
|
||||
case TokenType.TEXT:
|
||||
joined.push(tokens[pos++].data!);
|
||||
continue out;
|
||||
case TokenType.EQUALS:
|
||||
if (root == true) throw new TemplateSyntaxError('Unexpected equals', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
case TokenType.PIPE:
|
||||
if (root == true) throw new TemplateSyntaxError('Unexpected pipe', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
case TokenType.T_CLOSE:
|
||||
if (root == true) throw new TemplateSyntaxError('Unexpected closing tag', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
break out;
|
||||
case TokenType.T_OPEN:
|
||||
joined.push(parseTemplate());
|
||||
}
|
||||
}
|
||||
|
||||
stackDrop();
|
||||
|
||||
return joined.length == 1 ? joined[0] : {
|
||||
op: "concat",
|
||||
named: {},
|
||||
args: joined,
|
||||
srcRange: {
|
||||
file: file,
|
||||
from: tokens[start]?.from ?? 0,
|
||||
to: tokens[pos - 1]?.to ?? src.length - 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// parse templates
|
||||
function parseTemplate(): TemplateOp {
|
||||
let name: string;
|
||||
let args: TemplateOp[] = [];
|
||||
let nargs: {[index: string]: TemplateOp} = {};
|
||||
let start = pos;
|
||||
|
||||
stackPush();
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type == TokenType.T_OPEN) {
|
||||
pos++;
|
||||
} else throw new Error('Catastrophic failure');
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type == TokenType.TEXT) {
|
||||
name = tokens[pos++].data!;
|
||||
} else if (pos < tokens.length) {
|
||||
if (tokens[pos].type == TokenType.T_CLOSE) {
|
||||
throw new TemplateSyntaxError('Template name missing', { file: file, from: tokens[pos - 1].from, to: tokens[pos].to });
|
||||
} else throw new TemplateSyntaxError('Expected template name', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
} else throw new TemplateSyntaxError('Unexpected end of file');
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type == TokenType.PIPE) {
|
||||
pos++;
|
||||
|
||||
out: while (pos < tokens.length) {
|
||||
let argStart = pos;
|
||||
|
||||
let arg = parseConcat();
|
||||
|
||||
// this is some really nasty control flow im so sorry
|
||||
if (pos < tokens.length) {
|
||||
switch (tokens[pos].type) {
|
||||
case TokenType.EQUALS: // named arguments
|
||||
if (typeof arg != 'string') {
|
||||
throw new TemplateSyntaxError('Argument name must be a plain string', { file: file, from: tokens[argStart].from, to: tokens[pos - 1].to });
|
||||
}
|
||||
|
||||
pos++;
|
||||
if (pos < tokens.length) {
|
||||
let arg2 = parseConcat();
|
||||
nargs[arg] = arg2;
|
||||
if (pos < tokens.length) {
|
||||
switch (tokens[pos].type) {
|
||||
case TokenType.T_CLOSE: break out;
|
||||
case TokenType.PIPE: pos++;
|
||||
}
|
||||
} else throw new TemplateSyntaxError('syntax error');
|
||||
} else throw new TemplateSyntaxError('syntax error');
|
||||
break;
|
||||
case TokenType.T_CLOSE:
|
||||
args.push(arg);
|
||||
break out;
|
||||
case TokenType.PIPE:
|
||||
args.push(arg);
|
||||
pos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (pos < tokens.length && tokens[pos].type != TokenType.T_CLOSE) {
|
||||
throw new TemplateSyntaxError('Expected arguments or closing tag', { file: file, from: tokens[pos].from, to: tokens[pos].to });
|
||||
}
|
||||
|
||||
if (pos < tokens.length && tokens[pos].type == TokenType.T_CLOSE) {
|
||||
pos++;
|
||||
} else throw new TemplateSyntaxError('Template closing tag missing');
|
||||
|
||||
stackDrop();
|
||||
|
||||
// include is executed early
|
||||
if (name.toLowerCase() == 'include') {
|
||||
if (nargs['file'] != null) {
|
||||
// security check!
|
||||
if (!options.includeFiles) {
|
||||
throw new TemplateSyntaxError('You are not allowed to include files', { file: file, from: tokens[start].from, to: tokens[pos - 1].to });
|
||||
}
|
||||
|
||||
if (typeof nargs['file'] == 'string') {
|
||||
if (fs.existsSync(nargs['file'])) {
|
||||
let template = fs.readFileSync(nargs['file'], { encoding: 'utf-8' });
|
||||
|
||||
let tl = prepareTemplate(template, config);
|
||||
|
||||
return tl;
|
||||
} else throw new TemplateSyntaxError('File does not exist', { file: file, from: tokens[start].from, to: tokens[pos - 1].to});
|
||||
} else throw new TemplateSyntaxError('Include directive can not contain templates', { file: file, from: tokens[start].from, to: tokens[pos - 1].to});
|
||||
} else throw new TemplateSyntaxError(`Bad include directive`, { file: file, from: tokens[start].from, to: tokens[pos - 1].to});
|
||||
}
|
||||
|
||||
return {
|
||||
op: name.toLocaleLowerCase(),
|
||||
named: nargs,
|
||||
args: args,
|
||||
srcRange: {
|
||||
file: file,
|
||||
from: tokens[start]?.from ?? 0,
|
||||
to: tokens[pos - 1]?.to ?? src.length - 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let result = parseConcat(true);
|
||||
return result;
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
doctype html
|
||||
html.dark.sl-theme-dark(lang='en', prefix='og: https://ogp.me/ns')
|
||||
head
|
||||
//- this stuff
|
||||
meta(charset='UTF-8')
|
||||
meta(name='viewport', content='width=device-witdh, initial-scale=1.0')
|
||||
meta(name='theme-color' content='black')
|
||||
link(rel='stylesheet' href='/.css')
|
||||
|
||||
//- title
|
||||
title ass 🍑
|
||||
|
||||
//- embed data
|
||||
meta(property='og:title', content=embed.title)
|
||||
meta(property='og:description', content=embed.description)
|
||||
meta(property='og:site_name', content=embed.sitename)
|
||||
meta(property='og:type', content='image')
|
||||
meta(property='og:image', content=url)
|
||||
meta(property='og:url', content='.')
|
||||
meta(property='twitter:card', content='summary_large_image')
|
||||
|
||||
//- mixins
|
||||
include ../node_modules/shoelace-fontawesome-pug/sl-fa-mixin.pug
|
||||
include ../node_modules/shoelace-pug-loader/loader.pug
|
||||
|
||||
//- shoelace
|
||||
+slTheme('dark')
|
||||
+slAuto
|
||||
body.w-screen.h-screen.flex-col
|
||||
div.w-full.h-full.flex.justify-center.items-center.text-center
|
||||
main.flex.flex-col
|
||||
- let borderStyle = { 'border-color': 'var(--sl-color-neutral-200)' };
|
||||
header.border-t.border-x.flex.h-8.bg-stone-900(style=borderStyle)
|
||||
- let dividerStyle = { '--spacing': '0px', 'margin-left': '8px' };
|
||||
|
||||
//- uploader
|
||||
span
|
||||
sl-icon.p-2.align-middle(name='person-fill', label='uploader')
|
||||
| #{uploader}
|
||||
sl-divider(vertical, style=dividerStyle)
|
||||
//- file size
|
||||
span
|
||||
sl-icon.p-2.align-middle(name='database-fill', label='size')
|
||||
sl-format-bytes(value=size)
|
||||
sl-divider(vertical, style=dividerStyle)
|
||||
//- upload date
|
||||
span
|
||||
//- calendar is a funny word
|
||||
sl-icon.p-2.align-middle(name='calendar-fill', label='upload date')
|
||||
sl-format-date(value=time)
|
||||
sl-divider(vertical, style=dividerStyle)
|
||||
|
||||
//- spacer
|
||||
div.flex-grow
|
||||
|
||||
//- download button
|
||||
sl-divider(vertical, style=dividerStyle)
|
||||
span.float-right
|
||||
sl-icon-button(name='download', href=url, download, label='download')
|
||||
img.res-image.border-b.border-x(src=url, style=borderStyle)
|
Loading…
Reference in new issue