code:
import { readFile, stat } from 'fs-extra';
import * as MDI from 'markdown-it';
import * as mdiReplaceLinkPlugin from 'markdown-it-replace-link';
import * as mdiRubyPlugin from 'markdown-it-ruby';
import { dirname, posix, resolve } from 'path';
import { isCompressibleImage, isDocument, isResource } from '../fileExtensions';
import { TagSpec } from '../TagsSpec';
import { Node } from './../Data';
import { destructPath } from './destructPath';
import { distChaptersDir } from './dirs';
import { ErrorReporter, ReportableError } from './ErrorReporter';
import { getCreationTime } from './getCreationTime';
import { fPath, log } from './indentConsole';
import { Stats } from './Stats';

const { join } = posix;

export class LoaderError implements ReportableError {
  public constructor(
    private loaderName: string,
    private path: string,
    private error: any,
  ) {}
  public print(): void {
    log(`[[red|Error caused by loader [[cyan|${this.loaderName}]] on file [[yellow|${fPath(this.path)}]]:]]`);
    console.info(this.error);
  }
}

export class LoaderContext {
  public constructor(
    public readonly isDirectory: boolean,
    public readonly path: string,
    public readonly parentDistRelativePath: string,
    public readonly stats: Stats,
    public readonly production: boolean,
    private readonly errorReporter: ErrorReporter,
    private readonly tagsMap: ReadonlyMap<string, TagSpec> | null,
    public readonly buildFiles: Array<string> | null,
    public readonly nodeCache: Map<string, Node>,
  ) {}

  private distRelativePath: string | null = null;

  public updateCache(node: Node) {
    this.nodeCache.set(this.path, node);
  }

  public removeCache() {
    this.nodeCache.delete(this.path);
  }

  public getLastSuccess() {
    return this.nodeCache.get(this.path) ?? null;
  }

  public setDistFileName(fileName: string) {
    this.distRelativePath = (this.parentDistRelativePath === '' ? fileName : (this.parentDistRelativePath + '/' + fileName))
      .split(' ')
      .join('-');
  }

  public getNode() {
    return destructPath(this.path);
  }

  public getDistRelativePath() {
    if (this.distRelativePath === null) {
      throw new Error('A prior call to #setDistFileName() is required.');
    }
    return this.distRelativePath;
  }

  public getDistFullPath() {
    return resolve(distChaptersDir, this.getDistRelativePath());
  }

  public getCreationTime() {
    if (this.production) {
      return getCreationTime(this.path);
    } else {
      return 0;
    }
  }

  public readFile(): Promise<string> {
    return readFile(this.path, 'utf8');
  }

  public createMDI() {
    const htmlRelativePath = this.getDistRelativePath();
    return new MDI({
      replaceLink(link: string) {
        if (!link.startsWith('./')) {
          return link;
        }
        if (isResource(link) || isCompressibleImage(link)) {
          return join('./chapters', dirname(htmlRelativePath), link);
        }
        if (isDocument(link)) {
          return '#/chapter/' + join(dirname(htmlRelativePath), link);
        }
      },
    } as MDI.Options)
      .use(mdiReplaceLinkPlugin)
      .use(mdiRubyPlugin);
  }

  public async derive(subPath: string) {
    return new LoaderContext(
      (await stat(subPath)).isDirectory(),
      subPath,
      this.getDistRelativePath(),
      this.stats,
      this.production,
      this.errorReporter,
      this.tagsMap,
      this.buildFiles,
      this.nodeCache,
    );
  }

  public pushError(error: LoaderError) {
    this.errorReporter.reportError(error);
  }

  public isTagValid(tag: string) {
    const match = tag.match(/^(.+?)(?:（(.+)）)?$/);
    if (match === null) {
      return false;
    }
    if (this.tagsMap === null) {
      return true;
    }
    const tagSpec = this.tagsMap.get(match[1]);
    if (tagSpec === undefined) {
      return false;
    }
    if (match[2] === undefined) {
      return tagSpec.variants === null;
    } else {
      return tagSpec.variants !== null && tagSpec.variants.includes(match[2]);
    }
  }
}


code:
import { countCertainWord } from './countCertainWord';
import { countChars } from './countChars';
import { countParagraphs } from './countParagraphs';
import { keywords } from './keywords';

export class Stats {
  private charsCount = 0;
  private paragraphsCount = 0;
  private keywordsCount: Map<string, number> = new Map();
  public constructor(
    private isProduction: boolean,
  ) {
    keywords.forEach(keyword => {
      this.keywordsCount.set(keyword, 0);
    });
  }
  public processMarkdown(markdown: string): number | null {
    keywords.forEach(keyword => {
      const count = countCertainWord(markdown, keyword);
      if (count !== 0) {
        this.keywordsCount.set(keyword, this.keywordsCount.get(keyword)! + count);
      }
    });

    if (this.isProduction) {
      const deltaChars = countChars(markdown);
      this.charsCount += deltaChars;
      return deltaChars;
    } else {
      return null;
    }
  }
  public processHtml(html: string) {
    this.paragraphsCount += countParagraphs(html);
  }
  public getCharsCount() {
    return this.charsCount;
  }
  public getParagraphCount() {
    return this.paragraphsCount;
  }
  public getKeywordsCount() {
    return this.keywordsCount;
  }
}

repo_name: de/wearable

license: unknown


code:
export function countCertainWord(markdown: string, word: string) {
  let count = 0;
  let pointer = 0;
  while ((pointer = markdown.indexOf(word, pointer)) !== -1) {
    count++;
    pointer += word.length;
  }
  return count;
}


code:
import * as Unistring from 'unistring';

const ignoredTypes = new Set([6, 7, 26]);
export function countChars(markdown: string) {
  // JavaScript 的 string 其实是 UTF-16 序列，并且无法正常处理 Grapheme Cluster。
  // 因此我们使用 Unistring 这个库来做能够正确处理 Unicode 的字数统计。
  //
  // 在中文中，字数统计统计的是汉字的数量，而在英文中，统计的是词的数量。
  // 因为《可穿戴科技》同时含有英文和中文，我们需要能够同时正确处理中文和英文。
  // 因此，我们使用 Unistring 提供的 getWords 方法。这个方法会把输入的字符串根据
  // Unicode 规定的分词方案分割成一个词语数组。需要注意的是，Unicode 的分词方案
  // 并不会处理中文的分词，而是将每一个中文汉字当成一个单独的词语。恰好，这正是
  // 我们需要的。

  return Unistring.getWords(markdown)
    // 这里过滤掉空格和换行
    .filter((word: any) => !ignoredTypes.has(word.type))
    .length;
}


code:
export function countParagraphs(html: string) {
  let i = 0;
  let n = 0;
  while ((i = html.indexOf('<p>', i)) !== -1) {
    i += 3;
    n++;
  }
  return n;
}


code:
import { basename, relative } from 'path';
import { DisplayIndex } from '../Data';
import { chaptersDir } from './dirs';

function removeExtension(name: string) {
  if (name.includes('.')) {
    name = name.substr(0, name.lastIndexOf('.'));
  }
  return name;
}

const displayIndexRegex = /^(?:[1-9][0-9]*|0)(?:\.(?:[1-9][0-9]*|0))*$/;
function parseDisplayIndex(specifier: string): DisplayIndex {
  if (!displayIndexRegex.test(specifier)) {
    throw new Error(`Invalid display index specifier: "${specifier}".`);
  }
  return specifier.split('.').map(segment => {
    const num = +segment;
    if (num > Number.MAX_SAFE_INTEGER) {
      throw new Error(`The segment "${segment}" is too large.`);
    }
    return num;
  });
}

// Get basic displayName, displayIndex, name, relativePath from a full path
export function destructPath(fullPath: string): {
  displayName: string,
  displayIndex: DisplayIndex,
  sourceRelativePath: string,
} {
  const relativePath = relative(chaptersDir, fullPath);

  if (relativePath === '') {
    // Root
    return {
      displayName: '',
      displayIndex: [0],
      sourceRelativePath: relativePath,
    };
  }

  const name = basename(relativePath);

  let displayName;
  let displayIndex;

  const separatorIndex = name.indexOf(' - ');
  if (separatorIndex === -1) {
    displayIndex = parseDisplayIndex(removeExtension(name));
    displayName = `第 ${displayIndex} 章`;
  } else {
    displayIndex = parseDisplayIndex(name.substr(0, separatorIndex));
    displayName = removeExtension(name.substr(separatorIndex + 3));
  }

  return {
    displayName,
    displayIndex,
    sourceRelativePath: relativePath,
  };
}


code:
import { resolve } from 'path';
export const rootDir = resolve(__dirname, '../..');
export const staticDir = resolve(rootDir, 'static');
export const chaptersDir = resolve(rootDir, 'chapters');
export const distDir = resolve(rootDir, 'dist');
export const distChaptersDir = resolve(distDir, 'chapters');
export const tagsSpec = resolve(rootDir, 'TagsSpec.txt');


code:
import { execAsync } from './getCreationTime';

export async function execAsyncWithRetry(command: string) {
  let lastError: any;
  for (let retry = 1; retry <= 3; retry++) {
    try {
      return await execAsync(command);
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError;
}


code:
import { exec } from 'child_process';
import { promisify } from 'util';
import { execAsyncWithRetry } from './execAsyncWithRetry';

export const execAsync = promisify(exec);

function getTimestamp(stdout: string) {
  const trimmed = stdout.trim();
  if (trimmed === '') {
    return undefined;
  }
  return +trimmed.substr(trimmed.lastIndexOf('\n') + 1);
}

export async function getCreationTime(path: string) {
  // We first find the last merge commit that incorporates this file
  const mergeTime = getTimestamp(
    (await execAsyncWithRetry(`git log --merges --full-history --first-parent --format=%at "${path}"`)).stdout
  );
  if (mergeTime !== undefined) {
    return mergeTime;
  }
  // If such merge commit does not exist, we will simply use the file creation time
  const creationTime = getTimestamp(
    (await execAsyncWithRetry(`git log --format=%at --follow "${path}"`)).stdout
  );
  if (creationTime !== undefined) {
    return creationTime;
  }
  return 0;
}


code:
import { relative } from 'path';
import { rootDir } from './dirs';
import * as colorworks from 'colorworks';

const cw = colorworks.create();

const notLastIndent = cw.compile('[[gray| │  ]]');
const lastIndent = cw.compile('[[gray| ├─╴]]');

let indentation = 0;
export function indent() {
  indentation++;
}
export function dedent() {
  indentation--;
}
export function log(msg?: string) {
  if (msg === undefined) {
    console.info();
    return;
  }
  console.info(notLastIndent.repeat(Math.max(0, indentation - 1))
    + (indentation > 0 ? lastIndent : '') + cw.compile(msg));
}
/** Format path */
export function fPath(path: string) {
  return relative(rootDir, path).replace(/\\/g, '/');
}


code:
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { copy, ensureDir, readFile, writeFile } from 'fs-extra';
import { stringify } from 'javascript-stringify';
import { join, resolve } from 'path';
import { Data, Folder, Node } from '../Data';
import { TagSpec, TagsSpec } from '../TagsSpec';
import { chaptersDir, distChaptersDir, distDir, rootDir, staticDir, tagsSpec } from './dirs';
import { ErrorReporter } from './ErrorReporter';
import { log } from './indentConsole';
import { LoaderContext } from './LoaderContext';
import { load } from './loaders/Loader';
import { Stats } from './Stats';
import { loadTagsSpec, validateAndBuildTagMap } from './tagsSpecParser';
import {execAsyncWithRetry} from './execAsyncWithRetry';
import chokidar = require('chokidar');
import yargs = require('yargs');

const argv = yargs.options({
  production: { type: 'boolean', default: false },
  suppressError: { type: 'boolean', default: false, alias: 'suppress-error' },
  watch: { type: 'boolean', default: false, alias: 'w' },
}).argv;

if (!argv.watch) {
  build(null).catch(error => console.error(error));
} else {
  const watcher = chokidar.watch([chaptersDir, tagsSpec], {
    awaitWriteFinish: {
      stabilityThreshold: 500,
      pollInterval: 60,
    },
  });
  let building = false;
  const buildFiles = new Set<string>();
  const scheduleBuild = (path: string) => {
    buildFiles.add(path);
    if (building) {
      return;
    }
    building = true;
    (async () => {
      while (buildFiles.size > 0) {
        const toBeBuilt = [...buildFiles];
        buildFiles.clear();
        log(`[[green|Building [[yellow|${toBeBuilt.length}]] changed files...]]`);
        await build(toBeBuilt);
      }
      building = false;
    })().catch(error => console.error(error));
  };
  watcher.on('add', scheduleBuild);
  watcher.on('change', scheduleBuild);
  watcher.on('unlink', scheduleBuild);
}

const nodeCache = new Map<string, Node>();

async function readBuildNumber() {
  try {
    return (await execAsyncWithRetry(`git rev-list --count HEAD`)).stdout;
  } catch (error: any) {
    log(error);
    return 'Unknown';
  }
}

async function build(buildFiles: Array<string> | null) {
  const errorReporter = new ErrorReporter();
  const startTime = Date.now();

  await ensureDir(distChaptersDir);

  // Load tags spec
  let tagsSpec: null | TagsSpec = null;
  let tagsMap: null | ReadonlyMap<string, TagSpec> = null;
  let tagAliasMap: null | ReadonlyMap<string, string> = null;
  try {
    tagsSpec = await loadTagsSpec();
    ({ tagAliasMap, tagsMap } = await validateAndBuildTagMap(tagsSpec));
  } catch (error) {
    errorReporter.wrapAndReportError('Failed to load tags spec.', error as Error);
  }

  if (tagsSpec !== null) {
    await writeFile(
      resolve(distDir, 'tagsSpec.json'),
      JSON.stringify(tagsSpec, null, argv.production ? 0 : 2),
    );
  }

  const stats = new Stats(argv.production);

  const rootLoaderCtx = new LoaderContext(
    true,
    chaptersDir,
    '',
    stats,
    argv.production,
    errorReporter,
    tagsMap,
    buildFiles,
    nodeCache,
  );

  const data: Data = {
    chapterTree: await load(rootLoaderCtx)! as Folder,
    charsCount: argv.production ? stats.getCharsCount() : null,
    paragraphsCount: stats.getParagraphCount(),
    keywordsCount: [...stats.getKeywordsCount()].sort((a, b) => b[1] - a[1]),
    buildNumber: await readBuildNumber(),
    authorsInfo: JSON.parse(await readFile(join(rootDir, 'authors.json'), 'utf8')),
    buildError: errorReporter.hasError(),
    tags: tagsSpec === null ? [] : tagsSpec
      .sort((a, b) => b.priority - a.priority)
      .map(tagSpec => [
        tagSpec.tag,
        tagSpec.variants,
      ]),
    tagAliases: Array.from(tagAliasMap ?? []),
  };
  await writeFile(
    resolve(distDir, 'data.js'),
    `window.DATA=${stringify(data, null, argv.production ? 0 : 2, { skipUndefinedProperties: true })};`,
  );
  log('[[green|data.js created.]]');

  // Copy static
  await copy(staticDir, distDir);
  const indexPath = resolve(distDir, 'index.html');
  let result = await readFile(indexPath, 'utf8');
  const hash = (path: string) => createHash('sha256')
    .update(readFileSync(resolve(distDir, path)))
    .digest('hex')
    .substr(0, 12);
  result = result.replace(/<script src="(.*?)" defer><\/script>/g, (_, path) => {
    return `<script src="${path}?hash=${hash(path)}" defer></script>`;
  });
  result = result.replace(/<link rel="stylesheet" type="text\/css" href="(.*?)">/g, (_, path) => {
    return `<link rel="stylesheet" type="text/css" href="${path}?hash=${hash(path)}">`;
  });
  await writeFile(indexPath, result, 'utf8');

  log('[[green|Static copied.]]');
  log(`[[green|Time spent: [[cyan|${Date.now() - startTime}ms]].]]`);

  if (errorReporter.hasError()) {
    log();
    errorReporter.printAll();
    if (!argv.suppressError) {
      process.exit(1);
    }
  }
}


code:
export const keywords: Array<string> = [
  '姓陈的',
  '老陈',
  '陈傻子',
  '陈变态',
  '陈同学',
  '陈好人',
  '陈志鹏',
  '陈智障',
  '陈沙雕',
  '陈绅士',
  '陈色狼',
  '陈骗子',

  '高潮',
  '按摩棒',
  '按摩器',
  '仙女棒',
  '贞操带',

  'WTCD',

  '微积分',
];


code:
import { Node } from '../../Data';
import { dedent, indent, log, fPath } from '../indentConsole';
import { LoaderContext, LoaderError } from '../LoaderContext';
import { folderLoader } from './folderLoader';
import { imageLoader } from './imageLoader';
import { markdownLoader } from './markdownLoader';
import { resourceLoader } from './resourceLoader';
import { wtcdLoader } from './wtcdLoader';

export interface Loader {
  name: string;
  canLoad(ctx: LoaderContext): Promise<boolean>;
  isInBuildFiles(ctx: LoaderContext, buildFiles: Array<string>): boolean;
  load(ctx: LoaderContext): Promise<Node | null>;
}

const loaders: Array<Loader> = [
  folderLoader,
  markdownLoader,
  wtcdLoader,
  resourceLoader,
  imageLoader,
];

export async function load(ctx: LoaderContext) {
  for (const loader of loaders) {
    if (await loader.canLoad(ctx)) {
      if (ctx.buildFiles !== null && !loader.isInBuildFiles(ctx, ctx.buildFiles)) {
        return ctx.getLastSuccess();
      }
      log(`[[green|Load [[yellow|${fPath(ctx.path)}]] with [[cyan|${loader.name}]].]]`);
      let result;
      indent();
      try {
        result = await loader.load(ctx);
      } catch (error) {
        ctx.pushError(new LoaderError(loader.name, ctx.path, error));
        result = null;
      } finally {
        dedent();
      }
      if (result === null) {
        ctx.removeCache();
      } else {
        ctx.updateCache(result);
      }
      return result;
    }
  }
  return null;
}


code:
import { readdir, readFile } from 'fs-extra';
import { resolve } from 'path';
import { FolderMeta, Node } from '../../Data';
import { LoaderContext } from '../LoaderContext';
import { load, Loader } from './Loader';

function byDisplayIndex(a: Node, b: Node) {
  const largestCommonIndex = Math.min(a.displayIndex.length, b.displayIndex.length);
  for (let i = 0; i < largestCommonIndex; i++) {
    const diff = a.displayIndex[i] - b.displayIndex[i];
    if (diff !== 0) {
      return diff;
    }
  }
  return a.displayIndex.length - b.displayIndex.length;
}

export const folderLoader: Loader = {
  name: 'Folder Loader',
  async canLoad(ctx: LoaderContext) {
    return ctx.isDirectory;
  },
  isInBuildFiles(ctx: LoaderContext, buildFiles: Array<string>) {
    return buildFiles.some(path => path.startsWith(ctx.path));
  },
  async load(ctx: LoaderContext): Promise<Node | null> {
    const node = ctx.getNode();
    ctx.setDistFileName(node.displayName);
    const names = await readdir(ctx.path);
    const children: Array<Node> = [];
    let meta: FolderMeta = {};
    for (const name of names) {
      if (name === 'meta.json') {
        meta = JSON.parse(await readFile(resolve(ctx.path, name), 'utf8'));
      } else {
        const child = await load(await ctx.derive(resolve(ctx.path, name)));
        if (child !== null) {
          children.push(child);
        }
      }
    }
    children.sort(byDisplayIndex);
    let charsCount: number | null = 0;
    for (const child of children) {
      if (child.charsCount === null) {
        charsCount = null;
        break;
      }
      charsCount += child.charsCount;
    }
    return {
      ...meta,
      ...node,
      type: 'folder',
      children,
      charsCount,
    };
  },
};


code:
import { copyFile, mkdirp } from 'fs-extra';
import { basename, dirname, resolve } from 'path';
import { distChaptersDir } from '../dirs';
import { isCompressibleImage } from '../../fileExtensions';
import { fPath, log } from '../indentConsole';
import { LoaderContext } from '../LoaderContext';
import { Loader } from './Loader';
import sharp = require('sharp');

const LOW_RESOLUTION_WIDTH = 720;

export const imageLoader: Loader = {
  name: 'Image Loader',
  async canLoad(ctx: LoaderContext) {
    return !ctx.isDirectory && isCompressibleImage(ctx.path);
  },
  isInBuildFiles(ctx: LoaderContext, buildFiles: Array<string>) {
    return buildFiles.includes(ctx.path);
  },
  async load(ctx: LoaderContext): Promise<null> {
    ctx.setDistFileName(basename(ctx.path));
    const targetRelativePath = ctx.getDistRelativePath();
    const targetPath = resolve(distChaptersDir, targetRelativePath);
    await mkdirp(dirname(targetPath));

    const fullResolutionPath = targetPath.replace(/\.([^.]+)$/, '.full.$1');
    await copyFile(ctx.path, fullResolutionPath);
    log(`[[green|Copied to [[yellow|${fPath(fullResolutionPath)}]].]]`);

    if ((await sharp(ctx.path).metadata()).width! <= LOW_RESOLUTION_WIDTH) {
      await copyFile(ctx.path, targetPath);
      log(`[[green|Copied to [[yellow|${fPath(targetPath)}]].]]`);
    } else {
      await sharp(ctx.path).resize(LOW_RESOLUTION_WIDTH).toFile(targetPath);
      log(`[[green|Resized to [[yellow|${fPath(targetPath)}]].]]`);
    }

    return null;
  },
};


code:
import { mkdirp, writeFile } from 'fs-extra';
import { dirname } from 'path';
import { AuthorRole, ChapterFlags, ChapterFlagsMapped, MarkdownChapter } from '../../Data';
import { fPath, log } from '../indentConsole';
import { LoaderContext } from '../LoaderContext';
import { Loader } from './Loader';
import { parseAuthorSpecifier } from './parseAuthorSpecifier';
import { parseTagSpecifier } from './parseTagSpecifier';

const markdownFlags = new Map<string, ChapterFlags>([
  ['# 编写中', 'isEarlyAccess'],
  ['# 隐藏', 'hidden'],
  ['# 已弃坑', 'abandoned'],
]);

function readMarkdownFlags(markdown: string): [string, ChapterFlagsMapped] {
  const flagsMapped: ChapterFlagsMapped = {};
  let changed;
  do {
    changed = false;
    markdown = markdown.trimLeft();
    for (const [keyword, flag] of markdownFlags) {
      if (markdown.startsWith(keyword)) {
        changed = true;
        flagsMapped[flag] = true;
        markdown = markdown.substr(keyword.length);
      }
    }
  } while (changed);
  return [markdown, flagsMapped];
}

function loadProperty(markdown: string, key: string) {
  markdown = markdown.trimLeft();
  if (markdown.startsWith('- ')) {
    markdown = markdown.substr(2).trimLeft();
  }
  if (!markdown.startsWith(`${key}：`)) {
    return { markdown, value: null };
  }
  const lineBreakIndex = markdown.indexOf('\n');
  const value = markdown.substring(key.length + 1, lineBreakIndex).trim();
  markdown = markdown.substr(lineBreakIndex).trimLeft();
  return { markdown, value };
}

export const markdownLoader: Loader = {
  name: 'Markdown Loader',
  async canLoad(ctx: LoaderContext) {
    return !ctx.isDirectory && ctx.path.endsWith('.md');
  },
  isInBuildFiles(ctx: LoaderContext, buildFiles: Array<string>) {
    return buildFiles.includes(ctx.path);
  },
  async load(ctx: LoaderContext): Promise<MarkdownChapter> {
    let markdown = (await ctx.readFile()).trimLeft();

    let authors: Array<AuthorRole> = [];
    let authorSpecifier: string | null;
    ({ markdown, value: authorSpecifier } = loadProperty(markdown, '作者'));
    if (authorSpecifier !== null) {
      authors = parseAuthorSpecifier(authorSpecifier);
    }

    let tags: Array<string> | undefined;
    let tagSpecifier: string | null;
    ({ markdown, value: tagSpecifier } = loadProperty(markdown, '标签'));
    if (tagSpecifier !== null) {
      tags = parseTagSpecifier(tagSpecifier, tag => ctx.isTagValid(tag));
    }

    let chapterFlagsMapped: ChapterFlagsMapped;
    [markdown, chapterFlagsMapped] = readMarkdownFlags(markdown);
    const chapterCharCount = ctx.stats.processMarkdown(markdown);

    const node = ctx.getNode();
    log(`[[green|Use display name [[yellow|${fPath(node.displayName)}]].]]`);
    ctx.setDistFileName(node.displayName + '.html');
    const mdi = ctx.createMDI();
    const output = mdi.render(markdown);

    const creationTime = await ctx.getCreationTime();

    ctx.stats.processHtml(output);

    const htmlPath = ctx.getDistFullPath();
    await mkdirp(dirname(htmlPath));
    await writeFile(htmlPath, output);
    log(`[[green|Rendered to [[yellow|${fPath(htmlPath)}]].]]`);

    return {
      ...node,
      ...chapterFlagsMapped,
      type: 'Markdown',
      creationTime,
      htmlRelativePath: ctx.getDistRelativePath(),
      authors,
      tags,
      charsCount: chapterCharCount,
    };
  }
};


code:
import { AuthorRole } from '../../Data';

export function parseAuthorSpecifier(specifier: string): Array<AuthorRole> {
  return [...specifier.matchAll(/(?<=，|^)(.+?)(?:（(.+?)）)?(?=$|，)/g)]
    .map(match => ({ name: match[1], role: match[2] ?? '作者' }));
}


code:
export function parseTagSpecifier(tagSpecifier: string, tagVariantValidator: (tag: string) => boolean): Array<string> {
  const tagVariants = tagSpecifier.split('，');
  for (const tagVariant of tagVariants) {
    if (!tagVariantValidator(tagVariant)) {
      throw new Error(`Invalid tag: "${tagVariant}".`);
    }
  }
  return tagVariants;
}


code:
import { copyFile, mkdirp } from 'fs-extra';
import { basename, dirname, resolve } from 'path';
import { distChaptersDir } from '../dirs';
import { isResource } from '../../fileExtensions';
import { fPath, log } from '../indentConsole';
import { LoaderContext } from '../LoaderContext';
import { Loader } from './Loader';

export const resourceLoader: Loader = {
  name: 'Resource Loader',
  async canLoad(ctx: LoaderContext) {
    return !ctx.isDirectory && isResource(ctx.path);
  },
  isInBuildFiles(ctx: LoaderContext, buildFiles: Array<string>) {
    return buildFiles.includes(ctx.path);
  },
  async load(ctx: LoaderContext): Promise<null> {
    ctx.setDistFileName(basename(ctx.path));
    const targetRelativePath = ctx.getDistRelativePath();
    const targetPath = resolve(distChaptersDir, targetRelativePath);
    await mkdirp(dirname(targetPath));
    await copyFile(ctx.path, targetPath);

    log(`[[green|Copied to [[yellow|${fPath(targetPath)}]].]]`);
    return null;
  },
};


code:
import { mkdirp, pathExists, readFile, writeFile } from 'fs-extra';
import { dirname, parse as parsePath, resolve } from 'path';
import { Chapter } from '../../Data';
import { parse } from '../../wtcd/parse';
import { WTCDParseResult } from '../../wtcd/types';
import { dedent, fPath, indent, log } from '../indentConsole';
import { LoaderContext, LoaderError } from '../LoaderContext';
import { Loader } from './Loader';
import { parseAuthorSpecifier } from './parseAuthorSpecifier';

export const wtcdLoader: Loader = {
  name: 'WTCD Loader',
  async canLoad(ctx: LoaderContext) {
    return !ctx.isDirectory && ctx.path.endsWith('.wtcd');
  },
  isInBuildFiles(ctx: LoaderContext, buildFiles: Array<string>) {
    return buildFiles.includes(ctx.path);
  },
  async load(ctx: LoaderContext): Promise<Chapter> {
    const parsedPath = parsePath(ctx.path);
    const metaPath = resolve(parsedPath.dir, parsedPath.name + '.meta.json');
    const meta: any = {
      isEarlyAccess: false,
      hidden: false,
      authors: [],
      preferredReader: 'flow',
    };
    if (await pathExists(metaPath)) {
      const content = JSON.parse(await readFile(metaPath, 'utf8'));
      if (typeof content.isEarlyAccess === 'boolean') {
        meta.isEarlyAccess = content.isEarlyAccess;
      }
      if (typeof content.hidden === 'boolean') {
        meta.hidden = content.hidden;
      }
      if (typeof content.preferredReader === 'string') {
        if (content.preferredReader === 'game') {
          if (typeof content.slideAnimation === 'boolean') {
            meta.slideAnimation = content.slideAnimation;
          } else {
            meta.slideAnimation = true;
          }
        } else if (content.preferredReader !== 'flow') {
          throw new Error(`Unknown reader type: ${content.preferredReader}.`);
        }
        meta.preferredReader = content.preferredReader;
      }
      if (typeof content.authors === 'string') {
        meta.authors = parseAuthorSpecifier(content.authors);
      }
    }

    const source = await ctx.readFile();
    const node = ctx.getNode();
    log(`[[green|Use display name [[yellow|${fPath(node.displayName)}]].]]`);
    ctx.setDistFileName(node.displayName + '.html');
    const mdi = ctx.createMDI();

    const creationTime = await ctx.getCreationTime();

    const htmlPath = ctx.getDistFullPath();
    await mkdirp(dirname(htmlPath));

    let chapterCharCount: number | null = 0;

    log(`[[green|Start parsing WTCD for [[yellow|${fPath(ctx.path)}]].]]`);

    const logger = {
      info(msg: string): void {
        log(`[[green|${msg}]]`);
      },
      error(msg: string): void {
        log(`[[red|this.prefix ${msg}]]`);
      },
      warn(msg: string): void {
        log(`[[yellow|this.prefix ${msg}]]`);
      }
    };
    let wtcdParseResult: WTCDParseResult;
    indent();
    try {
      wtcdParseResult = parse({ source, mdi, logger, sourceMap: !ctx.production, markdownPreProcessor: markdown => {
        const deltaChars = ctx.stats.processMarkdown(markdown);
        if (deltaChars !== null && chapterCharCount !== null) {
          chapterCharCount += deltaChars;
        } else {
          chapterCharCount = null;
        }
        return markdown;
      }, htmlPostProcessor: html => {
        ctx.stats.processHtml(html);
        return html;
      }});
    } finally {
      dedent();
    }
    if (wtcdParseResult.error) {
      log(`[[red|WTCD parsing has errored: [[cyan|${wtcdParseResult.message}]].]]`);
      ctx.pushError(new LoaderError(wtcdLoader.name, ctx.path, wtcdParseResult.internalStack));
    }
    await writeFile(htmlPath, JSON.stringify(wtcdParseResult));

    log(`[[green|Parsed result written to [[yellow|${fPath(htmlPath)}]].]]`);

    return {
      ...node,
      ...meta,
      type: 'WTCD',
      creationTime,
      htmlRelativePath: ctx.getDistRelativePath(),
      charsCount: chapterCharCount,
    };
  },
};


code:
import { readFile } from 'fs-extra';
import { TagSpec, TagSpecDescLineSegment, TagsSpec } from '../TagsSpec';
import { tagsSpec } from './dirs';

function parseTagsSpec(content: string): TagsSpec {
  // Replace all line breaks with \n
  content = content.replace(/\r\n|\r|\n/g, '\n');

  // Extract all tags
  // Group 1: tag
  // Group 2: variants
  // Group 3: exclamation marks
  // Group 4: desc block
  return Array.from(content.matchAll(/([^\n]+?)(?:（([^\n]+?)）)?(！*)\n((?:[^\n]+\n)*)/g))
    .map(tagMatch => {
      const tag = tagMatch[1];
      const variants = (tagMatch[2] === undefined) ? null : tagMatch[2].split('/');
      const priority = tagMatch[3].length;
      const aliases: Array<string> = [];
      const desc = tagMatch[4]
        .split('\n')
        .map(line => line.trim())
        .filter(line => line !== '')
        .map(line => {
          let isPrerequisite = false;
          if (line.startsWith('+')) {
            isPrerequisite = true;
            line = line.substr(1);
          }
          if (line.startsWith('=')) {
            aliases.push(...line.substr(1).split('，'));
            return {
              isPrerequisite: false,
              segments: [{
                type: 'text' as 'text',
                content: `同义词：${line.substr(1)}`,
              }],
            };
          }
          // Group 1: text
          // Group 2: reference
          const segments: Array<TagSpecDescLineSegment> = Array.from(line.matchAll(/([^【]+)|【(.+?)】/g))
            .map(segmentMatch => {
              if (segmentMatch[1] !== undefined) {
                // text
                return {
                  type: 'text',
                  content: segmentMatch[1],
                };
              } else {
                return {
                  type: 'reference',
                  tag: segmentMatch[2],
                };
              }
            });
          return {
            isPrerequisite,
            segments,
          };
        });
      return {
        variants,
        tag,
        priority,
        desc,
        aliases,
      };
    });
}

export async function loadTagsSpec() {
  const content = await readFile(tagsSpec, 'utf8');
  return parseTagsSpec(content);
}

export function validateAndBuildTagMap(tagsSpec: TagsSpec) {
  const tagAliasMap = new Map<string, string>();
  const tagsMap = new Map<string, TagSpec>();
  tagsSpec.forEach(tagSpec => {
    if (tagsMap.has(tagSpec.tag)) {
      throw new Error(`Duplicated tag specification for tag "${tagSpec.tag}".`);
    }
    tagsMap.set(tagSpec.tag, tagSpec);
  });
  // Reference check
  tagsSpec.forEach(tagSpec => {
    tagSpec.desc.forEach(descLine => {
      descLine.segments.forEach(segment => {
        if (segment.type === 'reference') {
          if (!tagsMap.has(segment.tag)) {
            throw new Error(`Invalid tag reference to "${segment.tag}" from tag "${tagSpec.tag}".`);
          }
          const referencedTag = tagsMap.get(segment.tag)!;
          if (descLine.isPrerequisite && tagSpec.variants !== null && referencedTag.variants !== null) {
            // This tag and its prerequisite has variants.
            for (const variant of tagSpec.variants) {
              if (!referencedTag.variants.includes(variant)) {
                throw new Error(`Tag "${tagSpec.tag}"'s prerequisite "${referencedTag.tag}" lacks variant "${variant}".`);
              }
            }
          }
        }
      });
    });
    tagSpec.aliases.forEach(alias => {
      if (/\s/.test(alias)) {
        throw new Error(`Tag "${tagSpec.tag}" has invalid alias "${alias}".`);
      }
      if (tagAliasMap.has(alias)) {
        throw new Error(`Tag "${tagSpec.tag}" tries to define alias "${alias}", but the same alias was already defined by tag "${tagAliasMap.get(alias)}".`);
      }
      tagAliasMap.set(alias, tagSpec.tag);
    });
  });
  return { tagAliasMap, tagsMap };
}


code:
const resourceExtensions = ['.pdf', '.svg'];
export function isResource(path: string) {
  return resourceExtensions.some(extension => path.endsWith(extension));
}

const compressibleImageExtensions = ['.png', '.jpg'];
export function isCompressibleImage(path: string) {
  return compressibleImageExtensions.some(extension => path.endsWith(extension));
}

const documentExtensions = ['html'];
export function isDocument(path: string) {
  return documentExtensions.some(extension => path.endsWith(extension));
}


code:
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { mirrorSites } from './web/constant/mirrorSites';
import { thanks } from './web/constant/thanks';
import { padName } from './web/util/padName';

let content = readFileSync(join(__dirname, '..', 'readmeTemplate.md'), 'utf8');

content = content.replace(
  '☆☆☆☆☆【占位：镜像站列表】☆☆☆☆☆',
  mirrorSites.map(({ origin, provider }, index) => `- [镜像站 ${index + 1} | ${/^https?:\/\/(.*)$/.exec(origin)![1]}（感谢${padName(provider)}提供）](${origin})`).join('\n')
);

content = content.replace(
  '☆☆☆☆☆【占位：鸣谢列表】☆☆☆☆☆',
  thanks.map(({ name, link }) => (link === undefined)
    ? `- ${name}`
    : `- [${name}](${link})`
  ).join('\n'),
);

writeFileSync(join(__dirname, '..', 'Readme.md'), content, 'utf8');


code:
declare module 'markdown-it-replace-link';
declare module 'markdown-it-ruby';
declare module 'colorworks';
declare module 'unistring';
interface ArrayConstructor {
  isArray(arg: ReadonlyArray<any> | any): arg is ReadonlyArray<any>
}

type Remap<TElement> = {
  [TKey in keyof TElement]?: TElement[TKey] extends string
    ? (string | Array<string>)
    : TElement[TKey] extends CSSStyleDeclaration
      ? Partial<CSSStyleDeclaration>
      : TElement[TKey];
}

declare namespace JSX {
  type Element = HTMLElement;
  type IntrinsicElements = {
    [K in keyof HTMLElementTagNameMap]: Remap<HTMLElementTagNameMap[K]>;
  } & {
    [K in keyof HTMLElementDeprecatedTagNameMap]: Remap<HTMLElementDeprecatedTagNameMap[K]>;
  }
}


code:
@import (reference) 'common/button.less';

.content-container>.content .authors {
  >.outer-container {
    display: flex;
    flex-wrap: wrap;

    margin: -0.5em -0.5em 0.3em -0.5em;
    >div:extend(#common.button all) {
      display: flex;

      margin: 0.5em !important;
      padding: 1em !important;

      @media (min-width: 1651px) {
        flex: 1 0 calc(100% - 1em);
      }

      @media (min-width: 801px) and (max-width: 1650px) {
        flex: 1 0 calc((100% - 3em) / 3);

        max-width: calc((100% - 3em) / 3);
      }

      @media (min-width: 501px) and (max-width: 800px) {
        flex: 1 0 calc((100% - 2em) / 2);

        max-width: calc((100% - 2em) / 2);
      }

      @media (max-width: 500px) {
        flex: 0 1 100%;
      }
    }
    >.author-container {
      text-align: left;
      >.avatar {
        flex: 0 0;

        width: 44px;
        height: 44px;
        margin-right: 1em;

        border-radius: 3px;
      }
      >.author-role-container {
        flex: 1;
        >.role {
          pointer-events: none;

          opacity: 0.6;

          font-size: 0.8em;
        }
        >.name {
          margin-top: 0.1em;

          font-size: 1.2em;
        }
      }
    }
    >.reprint {
      display: flex;
      align-items: center;
      justify-content: center;
      >div {
        font-size: 1.5em;
      }
    }
  }
}


code:
body {
  overflow: hidden;

  margin: 0;
}
::-webkit-scrollbar-thumb {
  background-color: var(--paper-bg-color);
}
.rect {
  background-color: var(--rect-bg-color);
}


code:
@import (reference) 'common/button.less';
.button-container {
  >div:extend(#common.button all), >a:extend(#common.button all) {
    flex-grow: 1;

    margin: 0.4em !important;

    word-break: keep-all;

    &.rich {
      body.animation-enabled & {
        transition: background-color 0.3s, box-shadow 0.3s, color 0.3s;
      }
      &.disabled {
        color: var(--key-opacity-05);
        background-color: var(--key-opacity-004);
        box-shadow: none;
      }
      &.selected {
        border: white 0.2em;
        background-color: var(--button-color);
        box-shadow: rgba(0,0,0,0) 0 0 0, 0 0 0 2px var(--key) inset;
      }
    }
  }

  & {
    display: flex;
    flex-wrap: wrap;

    margin: -0.4em;
  }
}


code:
@import (reference) 'common/layerCard.less';
@import (reference) 'common/button.less';

.content-container>.content .content-block.comments {
  >p {
    margin-bottom: 0;
  }
  >.create-comment:extend(#common.button all) {
    margin-top: 2vh;

    font-size: 2em;
    line-height: 1em;
  }
  >.comment:extend(#common.layer-card all) {
    position: relative;

    margin-top: 2vh;

    >.avatar {
      width: 44px;
      height: 44px;

      border-radius: 3px;
    }
    >.author {
      position: absolute;
      top: 3vh;
      left: calc(44px + 4.5vh);

      font-size: 1.15em;
    }
    >.time {
      position: absolute;
      top: calc(3vh + 2.3em);
      left: calc(44px + 4.5vh);

      font-size: 0.7em;
    }
    >p {
      margin-bottom: 0;
    }
    body.animation-enabled &>.action {
      transition: background-color 0.12s linear, opacity 0.12s linear;
    }
    >.action {
      float: right;

      padding: 5px 10px;

      cursor: pointer;

      opacity: 0.8;
      border-radius: 3px;
    }
    >.action:hover {
      background-color: var(--key-opacity-01);
    }

    >.action:active {
      background-color: var(--key-opacity-05);
    }
  }
}


code:
@import (reference) 'common/layerCard.less';

#common.button:extend(#common.layer-card all) {
  position: relative;

  padding: 1em 1.2em !important;

  user-select: none;
  text-align: center;

  &::before {
    position: absolute;
    top: 0;
    left: 0;

    width: 100%;
    height: 100%;

    content: '';
    cursor: pointer;

    opacity: 0;
    background-color: var(--key);
    body.animation-enabled & {
      transition: opacity 0.12s;
    }
  }

  &:hover::before {
    opacity: 0.06;
  }
  &:active::before {
    opacity: 0.04;
  }

  &.disabled::before {
    cursor: default;

    opacity: 0;
  }
}


code:
#common.layer-card {
  overflow: hidden;

  padding: 3vh;

  border-radius: 5px;
  background-color: var(--comment-color);
  box-shadow: rgba(0,0,0,0.24) 0 1.01372px 2.32903px;
}


code:
@import './variables.less';

#common.material-content {
  color: var(--key);
  background-color: var(--paper-bg-color);

  font-family: var(--font-family);

  >:first-child, >:first-child>:first-child, >:first-child>:first-child>:first-child {
    margin-top: 0;
  }
  >:last-child, >:last-child>:last-child, >:last-child>:last-child>:last-child {
    margin-bottom: 0;
  }
  @media (min-width: 901px) {
    padding: @material-content-regular-padding;
  }
  @media (max-width: 900px) {
    padding: 1em;
  }
  code {
    cursor: text;

    font-family: var(--font-family-mono);
    font-size: 1em;
  }
  :not(pre)>code {
    margin: 0;
    padding: 0.12em 0.3em;

    word-break: break-all;

    border-radius: 3px;
    background-color: var(--key-opacity-007);
  }
  pre {
    overflow-x: auto;

    padding: 2em;

    border-radius: 0.8em;
    background-color: var(--key-opacity-007);
  }
  pre.wrapping {
    white-space: normal;
  }
  blockquote {
    margin: 0;

    opacity: 0.6;

    font-style: italic;
  }
  a.regular, span.anchor-style {
    cursor: pointer;

    color: var(--link-color);
    &:hover {
      color: var(--link-hover-color);
    }
    &:active {
      color: var(--link-active-color);
    }
  }
  a.dimmed {
    margin-top: 2em;

    opacity: 0.45;

    body.animation-enabled & {
      transition: opacity 0.12s;
    }
    &:hover {
      opacity: 0.75;
    }
  }
}


code:
@import (reference) 'common/materialContent.less';
@import (reference) 'common/button.less';
@import 'variables.less';

.content-container>.content {
  .content-block:extend(#common.material-content all) {
    body.animation-enabled & {
      transition: max-width 0.5s, background-color 0.3s, color 0.3s;
    }
    @media (min-width: 901px) {
      border-radius: 6px;
      box-shadow: rgba(0,0,0,0.12) 0 0.55009px 2.4261px, rgba(0,0,0,0.24) 0 1.01372px 2.32903px;
    }
    &.warning {
      background-color: var(--content-block-warning-color);
    }
    .page-switcher {
      display: flex;

      margin-top: 2vh;
      >a:extend(#common.button all) {
        flex: 1;

        text-align: center;
      }
      a+a {
        margin-left: 0.8em;
      }
    }
  }
  >.left-side-container, >.right-side-container {
    @media (min-width: 1651px) {
      position: absolute;
      top: 0;

      width: @side-block-width;

      &.left-side-container {
        left: calc((100vw - @content-block-width) / 2 - @side-block-width - @block-spacing);
      }
      &.right-side-container {
        right: calc((100vw - @content-block-width) / 2 - @side-block-width - @block-spacing);
      }
      .content-block {
        padding: @side-block-padding;
      }
    }
    @media (min-width: 901px) {
      .content-block {
        margin-bottom: 1.7vw;
      }
    }
  }
  >div {
    @media (min-width: 901px) {
      width: @content-block-width;
      max-width: 100%;
      margin: 1.7vw auto @block-spacing auto;
      body.layout-side & {
        max-width: calc(100vw - 50vh - 6vw);
      }
      body.layout-main & {
        max-width: calc(100vw - 6vw);
      }
    }
    @media (max-width: 900px) {
      width: 100vw;
    }
  }
  >.slide-container {
    position: relative;
    >.height-holder {
      body.animation-enabled & {
        transition: height 0.5s;
      }
    }
    &.in-transition>.content-block {
      position: absolute;
      left: 0;
    }
    >.content-block {
      width: 100%;
      body.animation-enabled & {
        transition: left 0.5s;
      };
      &.left {
        left: -100vw !important;
      }
      &.right {
        left: 100vw !important;
      }
    }
  }
}


code:
@import 'variables.less';

.content-container {
  position: fixed;
  z-index: 2;

  overflow-x: hidden;

  height: 100%;

  outline: none;
  body.animation-enabled & {
    transition: width 0.5s, left 0.5s, top 0.5s;
  }
  body.layout-off & {
    top: 100vh;
    left: 0;

    width: 100%;
  }
  body.layout-main & {
    top: @navbar-height;
    left: 0;

    width: 100%;
    height: calc(100% - @navbar-height);
  }
  body.layout-side & {
    @media (min-width: 901px) {
      top: 0;
      left: 50vh;

      width: calc(100vw - 50vh);
    }
    @media (max-width: 900px) {
      top: 100vh;
    }
  }
  >.content {
    position: absolute;
    left: 0;

    overflow-x: hidden;
    overflow-y: scroll;

    width: 100%;
    height: 100%;

    outline: none;
    body.animation-enabled & {
      // transition: transform 5s linear;
      transition: left 0.5s;
    }
    &.left {
      // transform: translateX(-1000px);
      left: -100vw;
    }
    &.right {
      // transform: translateX(1000px);
      left: 100vw;
    }
  }
}


code:
div {
  box-sizing: border-box;
}
a {
  text-decoration: none;

  color: inherit;
}
p {
  line-height: 1.6em;
}
li {
  line-height: 1.6em;
}
@media (max-width: 900px) {
  li {
    margin-left: -0.8em;
  }
}
img {
  width: 100%;

  border-radius: 6px;
}
h1 {
  font-size: 2.2em;
}
h2 {
  font-size: 1.8em;
}
h3 {
  font-size: 1.55em;
}
h4 {
  font-size: 1.4em;
}
h5 {
  font-size: 1.25em;
}
h6 {
  font-size: 1.1em;
}

@less-margin: 0.6em;
@more-margin: 0.8em;
h1, h2, h3, h4, h5, h6 {
  margin-top: @more-margin;
  margin-bottom: @less-margin;
}
p, :not(li)>ul, :not(li)>ol {
  margin-top: @less-margin;
  margin-bottom: @more-margin;
}


code:
.hint {
  position: fixed;
  z-index: 4;
  top: 10%;
  left: 50%;

  padding: 0.7em 0.9em;

  transform: translateX(-50%);
  text-align: center;

  opacity: 1;
  color: white;
  border-radius: 8px;
  background-color: rgba(0, 0, 0, 0.5);

  font-size: 1.2em;

  -webkit-backdrop-filter: blur(10px);
          backdrop-filter: blur(10px);
  ;
  body.animation-enabled & {
    transition: opacity 0.3s;
  }
}


code:
@import 'legacy.less';
@import 'menu.less';
@import 'rect.less';
@import 'body.less';
@import 'contentContainer.less';
@import 'content.less';
@import 'comment.less';
@import 'nsfwWarning.less';
@import 'modal.less';
@import 'buttonContainer.less';
@import 'wtcdGame.less';
@import 'hint.less';
@import 'input.less';
@import 'table.less';
@import 'generic.less';
@import 'visitCount.less';
@import 'authors.less';
@import 'wtcup.less';
@import 'navbar.less';
@import 'title.less';
@import 'mirrorSite.less';
@import 'taggingTool.less';
@import 'tag.less';
@import 'recommendedChapters.less';
@import 'preloader.less';

@import 'common/button.less';
@import 'common/layerCard.less';
@import 'common/materialContent.less';


code:
.input-group {
  display: flex;

  margin-top: 0.6em;
  margin-bottom: 0.6em;

  >span {
    flex: 0;

    white-space: nowrap;
  }
  >input {
    flex: 1;

    min-width: 0;
    padding: 0.2em;

    color: var(--key);
    border: none;
    border-radius: 4px;
    outline: none;
    background-color: transparent;
    background-color: var(--comment-color);
    box-shadow: 0 1px 3px rgba(0,0,0,0.2) inset, 0 1px 2px rgba(0,0,0,0.3) inset;

    font-size: 1em;
  }
}

textarea.general {
  overflow-y: hidden;

  width: 100%;
  padding: 0.6em;

  resize: none;

  color: var(--key);
  border: none;
  border-radius: 4px;
  outline: none;
  background-color: var(--comment-color);
  box-shadow: 0 1px 3px rgba(0,0,0,0.2) inset, 0 1px 2px rgba(0,0,0,0.3) inset;

  font-family: var(--font-family);
  font-size: 1em;
  &.large {
    height: 120px;
  }
  &.small {
    height: 40px;
  }
}


code:
body.animation-enabled {
  transition: background-color 0.3s;
}

.display-none {
  display: none !important;
}

@keyframes moving-strips {
  0% {
    transform: translateX(-283px);
  }
  100% {
    transform: translateX(283px);
  }
}

// body.animation-enabled .rect {
//   transition: background-color 0.3s, width 0.5s, height 0.5s, top 0.5s, left 0.5s, transform 0.5s;
// }
// .rect {
//   position: fixed;
//   top: -50vmax;
//   left: 35vmax;

//   overflow: hidden;

//   width: 75vh;
//   height: 200vmax;

//   transform: rotate(40deg);

//   outline: none;
//   background-color: #555;
// }
// .rect.reading {
//   z-index: 1;

//   overflow-y: scroll;
//   -webkit-overflow-scrolling: touch;
// }
// body.animation-enabled .rect>div {
//   transition: background-color 0.3s, color 0.3s, opacity 0.5s;
// }
// .rect>div {
//   opacity: 0;

//   font-family: var(--font-family);
// }
@media (min-width: 901px) {
  // .rect.reading {
  //   top: 0;

  //   height: 100%;

  //   transform: rotate(0);
  // }
  // .rect.reading.side {
  //   left: 50vh;

  //   width: calc(100vw - 50vh);
  // }
  // .rect.reading.main {
  //   left: 0;

  //   width: 100vw;
  // }
  .rect>div {
    margin: 2vw auto;
    padding: calc(1.8em + 1.2vw);

    border-radius: 6px;
    box-shadow: rgba(0,0,0,0.12) 0 0.55009px 2.4261px, rgba(0,0,0,0.24) 0 1.01372px 2.32903px;
  }
  .rect.side>div {
    width: 800px;
    max-width: calc(100vw - 50vh - 6vw);
  }
  .rect.main>div {
    width: 800px;
    max-width: calc(100vw - 6vw);
  }
  .rect.reading>div {
    opacity: 1;
  }
}
@media (max-width: 900px) {
  // .rect {
  //   top: -30vmax;

  //   width: 90vh;

  //   transform: rotate(60deg);
  // }
  // .rect.reading:not(.side) {
  //   top: 0;
  //   left: 0;

  //   width: 100vw;
  //   height: 100%;

  //   transform: none;
  // }
  .rect>div {
    width: 100vw;
    padding: 1em;
  }
  .rect.reading:not(.side)>div {
    opacity: 1;
  }
}
// .rect>div>:first-child, .rect>div>:first-child>:first-child, .rect>div>:first-child>:first-child>:first-child {
//   margin-top: 0;
// }
// .rect>div>:last-child, .rect>div>:last-child>:last-child, .rect>div>:last-child>:last-child>:last-child {
//   margin-bottom: 0;
// }
// .rect>.content code {
//   cursor: text;

//   font-family: var(--font-family-mono);
//   font-size: 1em;
// }
// .rect>.content :not(pre)>code {
//   margin: 0;
//   padding: 0.12em 0.3em;

//   word-break: break-all;

//   border-radius: 3px;
//   background-color: var(--key-opacity-007);
// }
// .rect>.content pre {
//   overflow-x: auto;

//   padding: 2em;

//   border-radius: 0.8em;
//   background-color: var(--key-opacity-007);
// }
// .rect>.content blockquote {
//   margin: 0;

//   opacity: 0.6;

//   font-style: italic;
// }
// .rect>.content blockquote>p {
//   margin-top: 0;
// }

// .rect>.comments>p {
//   margin-bottom: 0;
// }
// .rect>.comments>div {
//   margin-top: 2vh;

//   box-shadow: rgba(0,0,0,0.24) 0 1.01372px 2.32903px;
// }
// .rect>.comments>.create-comment {
//   user-select: none;
//   text-align: center;

//   font-size: 2em;
//   line-height: 1em;
// }
// body.animation-enabled .rect>.comments>.create-comment::before {
//   transition: opacity 0.12s;
// }
// .rect>.comments>.create-comment::before {
//   position: absolute;
//   top: 0;
//   left: 0;

//   width: 100%;
//   height: 100%;

//   content: '';
//   cursor: pointer;

//   opacity: 0;
// }
// .rect>.comments>.create-comment:hover::before {
//   opacity: 0.1;
// }
// .rect>.comments>.create-comment:active::before {
//   opacity: 0.04;
// }

// .rect>.comments>div {
//   position: relative;

//   overflow: hidden;

//   padding: 3vh;

//   border-radius: 5px;
// }
// .rect>.comments>.comment>.avatar {
//   width: 44px;
//   height: 44px;

//   border-radius: 3px;
// }
// .rect>.comments>.comment>.author {
//   position: absolute;
//   top: 3vh;
//   left: calc(44px + 4.5vh);

//   font-size: 1.15em;
// }
// .rect>.comments>.comment>.time {
//   position: absolute;
//   top: calc(3vh + 2.3em);
//   left: calc(44px + 4.5vh);

//   font-size: 0.7em;
// }
// .rect>.comments>.comment>p {
//   margin-bottom: 0;
// }

.button {
  position: relative;

  display: inline-block;

  cursor: pointer;
  user-select: none;
}
.button:active {
  opacity: 0.6;
}

.button:active::after {
  opacity: 0.6;
  body.animation-enabled & {
    transition: background-color 0.3s;
  }
}
body.animation-enabled .button::after {
  transition: background-color 0.3s, transform 0.2s, transform-origin 0.2s;
}
.button::after {
  position: absolute;
  top: 102%;
  left: 0;

  width: 100%;
  height: 0.06em;

  content: '';
  transform: scaleX(0);
  transform-origin: 20%;

  border-radius: 0.03em;

  will-change: transform;
}
.button:hover::after {
  transform: scaleX(1);
  transform-origin: 0;
}

@media (min-width: 901px) {
  ::-webkit-scrollbar {
    width: 18px;
    height: 18px;
  }

  ::-webkit-scrollbar-thumb {
    transition: border-width 0.5s;

    border: 5px solid rgba(0, 0, 0, 0);
    border-radius: 0.8em;
    background-clip: padding-box;
  }

  ::-webkit-scrollbar-thumb:hover {
    border-width: 3px;
  }
}

.wtcd-group-container+.wtcd-group-container::before {
  display: block;

  width: 100%;
  height: 1px;
  margin: 1.6em 0;

  content: '';

  background-color: var(--key-opacity-05);
}
body.animation-enabled .wtcd-button.candidate::before, body.animation-enabled .wtcd-button.selected::before {
  transition: opacity 0.12s;
}
.wtcd-button.candidate::before, .wtcd-button.selected::before {
  position: absolute;
  top: 0;
  left: 0;

  width: 100%;
  height: 100%;

  content: '';
  pointer-events: none;

  opacity: 0;
  background-color: var(--key);
}
.wtcd-button.candidate:hover::before, .wtcd-button.selected:hover::before {
  opacity: 0.1;
}
.wtcd-button.candidate:active::before, .wtcd-button.selected:active::before {
  opacity: 0.04;
}
body.animation-enabled .wtcd-button {
  transition: background-color 0.3s, box-shadow 0.3s, color 0.3s;
}
.wtcd-button {
  position: relative;

  overflow: hidden;

  margin: 0.8em 0;
  padding: 1em 1.2em;

  border-radius: 0.5em;
}
.wtcd-button.disabled {
  color: var(--key-opacity-05);
  background: repeating-linear-gradient(45deg, var(--key-opacity-004), var(--key-opacity-004) 15px, rgba(0,0,0,0) 15px, rgba(0,0,0,0) 30px);
  box-shadow: none;
}
.wtcd-button.candidate {
  cursor: pointer;

  background-color: var(--button-color);
  box-shadow: rgba(0,0,0,0.24) 0 1.01372px 2.32903px, 0 0 0 0 rgba(255,255,255,0) inset;
}
.wtcd-button.selected {
  cursor: pointer;

  border: white 0.2em;
  background-color: var(--button-color);
  box-shadow: rgba(0,0,0,0) 0 0 0, 0 0 0 2px var(--key) inset;
}
.wtcd-button.unselected {
  color: var(--key-opacity-05);
  background-color: var(--key-opacity-004);
  box-shadow: none;
}


code:
.menu-control, .menu {
  padding-left: 5vh;

  opacity: 1;
  color: var(--key);

  font-family: 'ZCOOL QingKe HuangYou', sans-serif;
  body.animation-enabled & {
    transition: color 0.3s, opacity 0.3s, transform 0.3s;
  }
  &.hidden {
    transform: translateX(-5vh);
    pointer-events: none;

    opacity: 0;
  }
}
.menu-control {
  position: fixed;
  top: 26vh;
  right: 0;
  left: 0;

  >.path, >.back {
    transform: scaleX(1.2);
    transform-origin: 0;
  }
  >.path {
    position: relative;

    height: 2vh;
    margin-bottom: 0.3vh;
    padding-left: 3.5vh;

    font-size: 2vh;
    line-height: 2vh;
    svg {
      position: absolute;

      width: 1vh;
      height: 1vh;

      transform: scale(2.6) translate(-0.8vh, 0.2vh);

      path {
        fill: var(--key);
        body.animation-enabled & {
          transition: fill 0.3s;
        }
      }
    }
    >span {
      display: inline-block;

      transform: translateX(0);

      body.animation-enabled & {
        transition: opacity 0.3s, transform 0.3s, left 0.3s;
      }
      span {
        opacity: 0.3;
      }
      span, a {
        margin-right: 0.4vh;
      }
      &.pre-entering {
        transform: translateX(10px);

        opacity: 0;
      }
      &.exiting {
        position: absolute;

        opacity: 0;
        &.first {
          body.animation-enabled & {
            transition: opacity 0.05s, transform 0.3s, left 0.3s;
            transition-delay: 5s, 0s, 0s;
          }
        }
      }
    }
  }
  >.back {
    margin: 0.8vh 0 0.8vh 0.5vh;

    font-size: 5vh;
    line-height: 5vh;

    &::before {
      position: absolute;
      top: 1vh;
      right: calc(100% + 0.8vh);

      content: '\25C0\FE0E';

      font-size: 3vh;
      line-height: 3vh;
    }
  }
}
.menu {
  position: fixed;
  top: 35vh;
  right: 0;
  bottom: 2vh;
  left: 0;

  display: flex;
  align-content: flex-start;
  flex-direction: column;

  &.main {
    top: 28vh;
    >.flex {
      >div:not(.force-small), >a:not(.force-small) {
        margin: 0.4vh 0 0.8vh 0.5vh;

        font-size: 5vh;
        line-height: 5vh;
        &.button {
          .main.menu &::before {
            top: 1vh;
            right: calc(100% + 0.8vh);

            font-size: 3vh;
            line-height: 3vh;
          }
        }
      }
    }
  }

  @media (max-width: 900px) {
    overflow-y: auto;
  }
  @media (min-width: 901px) {
    overflow-x: auto;
    overflow-y: hidden;
    flex-wrap: wrap;
  }

  >.flex {
    transform: scaleX(1.2);
    transform-origin: 0;

    @media (max-width: 900px) {
      width: calc((100vw - 8vh) / 1.2);
    }
    @media (min-width: 901px) {
      max-width: 380px;
      margin-right: 120px;
      padding-right: 30px;
    }

    >div, >a {
      margin: 0.2vh 0 0.2vh 0.5vh;

      font-size: 3vh;
      line-height: 3.2vh;
      &.button {
        &::before {
          position: absolute;
          top: 0.5vh;
          right: calc(100% + 0.8vh);

          font-size: 2vh;
          line-height: 2vh;
        }
      }
    }

    .button.icon {
      &::before {
        transform: translate(0.4vh, 0.03em) scaleX(0.833);

        font-family: 'Material Icons';
        font-size: 1.1em !important;
      }
      &.link::before {
        content: 'link';
      }
      &.equalizer::before {
        content: 'equalizer';
      }
      &.folder::before {
        content: 'folder_open';
      }
      &.folder:hover::before {
        content: 'folder';
      }
      &.file::before {
        content: 'insert_drive_file';
      }
      &.game::before {
        content: 'games';
      }
      &.notification::before {
        content: 'notifications_active';
      }
      &.list::before {
        content: 'list';
      }
      &.calender::before {
        content: 'today';
      }
      &.history::before {
        content: 'history';
      }
      &.clear::before {
        content: 'clear';
      }
      &.person::before {
        content: 'person';
      }
      &.icon-tag::before {
        content: 'sell';
      }
    }
    .button.selectable::before {
      content: '>';
      transform: translateX(-50%);

      opacity: 0;
      body.animation-enabled & {
        transition: opacity 0.3s, transform 0.3s;
      }
    }
    .button.selectable.selected::before {
      transform: none;

      opacity: 1;
    }

    .item-side {
      display: inline-block;

      opacity: 0.6;
    }

    .prepend .item-side {
      padding-right: 5px;
    }
    .append .item-side {
      padding-left: 5px;
    }
  }
}


code:
.mirror-site-modal>.button-container>a {
  width: 100%;

  text-align: left;
  >h2 {
    margin: 0 0 0.2em 0;
  }
  p {
    margin: 0;

    line-height: 1.4em;
  }
}

.mirror-hint {
  position: fixed;
  right: 0.4em;
  bottom: 0.4em;

  opacity: 0.6;
  color: var(--key);
  >span {
    cursor: pointer;
    text-decoration: underline;
  }
}


code:
@import (reference) 'common/materialContent.less';

// 所有和 Modal 相关的 CSS
.modal-holder {
  >.modal-container {
    position: fixed;
    z-index: 3;

    display: flex;
    align-items: center;
    justify-content: center;

    width: 100%;
    height: 100%;

    opacity: 1;
    background-color: var(--key-opacity-01);

    -webkit-backdrop-filter: blur(10px);
            backdrop-filter: blur(10px);
    body.animation-enabled & {
      transition: background-color 0.3s, opacity 0.3s;
    }
    >.modal:extend(#common.material-content all) {
      overflow-y: auto;

      width: 600px;
      max-width: 92vw;
      max-height: calc(100% - 8vw);

      transform: scale(1);

      border-radius: 6px;
      box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22);
      body.animation-enabled & {
        transition: transform 0.3s;
      }
      @media (max-width: 900px) {
        padding: 6vw !important;
      }
      &::-webkit-scrollbar-thumb {
        background-color: var(--rect-bg-color);
      }
    }
    &.closed {
      pointer-events: none;

      opacity: 0;

      >.modal {
        transform: scale(0);
      }
    }
  }
}


code:
@import 'variables.less';

.navbar {
  position: fixed;
  z-index: 3;
  left: -10px;

  display: flex;
  overflow-y: hidden;
  align-items: center;

  width: calc(100% + 20px);
  height: @navbar-height;

  transform: translateY(calc(-@navbar-height - 10px));
  white-space: pre;

  color: var(--key);
  background-color: var(--paper-bg-color);
  box-shadow: 0 2px 6px 2px rgba(0, 0, 0, 0.4);

  font-size: 1.2em;
  >.progress-indicator {
    position: absolute;
    left: 10px;

    width: calc(100% - 20px);
    height: @navbar-height;

    transform: scaleX(0);
    transform-origin: 0;
    pointer-events: none;

    opacity: 0.08;
    background-color: var(--key);
    body.animation-enabled & {
      transition: transform 0.2s, opacity 0.3s;
    }
    &.hidden {
      opacity: 0;
    }
  }
  &.flat {
    box-shadow: rgba(0,0,0,0.12) 0 0.55009px 2.4261px, rgba(0,0,0,0.24) 0 1.01372px 2.32903px;
  }
  >svg {
    flex-shrink: 0;

    width: 1.4em;
    height: 1.4em;
    margin-right: calc(0.3em);

    cursor: pointer;

    @media (min-width: 901px) {
      margin-left: calc(1rem + 1.2vw + 10px);
    }
    @media (max-width: 900px) {
      margin-left: calc(1rem + 10px);
    }

    path {
      fill: var(--key);
    }
  }
  >.title {
    margin-right: 0.3em;

    font-weight: bold;
  }
  .navbar-path-container-scroll {
    flex: 1;

    margin-left: -0.3em;
    @media (pointer: coarse) {
      overflow-x: auto;
      &::-webkit-scrollbar {
        width: 0;
        height: 0;
      }
    }
    @media (pointer: fine) {
      overflow-x: hidden;
    }

    .navbar-path-container {
      display: flex;
      >.arrow, >.anchor {
        transform: translateY(0);
        body.animation-enabled & {
          transition: transform 0.3s;
        }
        &.exiting {
          position: absolute;

          transform: translateY(-@navbar-height);
        }
        &.entering {
          transform: translateY(@navbar-height);
        }
        &:last-child {
          @media (min-width: 901px) {
            padding-right: calc(1rem + 1.2vw + 10px);
          }
          @media (max-width: 900px) {
            padding-right: calc(1rem + 10px);
          }
        }
      }
      >.arrow {
        padding-right: 0.3em;
        padding-left: 0.3em;

        opacity: 0.3;
      }
    }
  }
  body.animation-enabled & {
    transition: transform 0.5s, box-shadow 0.3s;
  }
  body.layout-main & {
    transform: translateY(0);
  }
}


code:
.nsfw-warning {
  position: fixed;
  z-index: 5;

  width: 100vw;
  height: 100%;

  cursor: pointer;

  -webkit-backdrop-filter: blur(10px);
          backdrop-filter: blur(10px);

  body.animation-enabled & {
    transition: opacity 0.3s;
  }

  .warning-backdrop {
    position: fixed;
    left: -300px;

    width: calc(100vw + 600px);
    height: 100%;

    background: repeating-linear-gradient(
    45deg,
    rgba(58, 58, 0, 0.85),
    rgba(58, 58, 0, 0.85) 200px,
    rgba(0, 0, 0, 0.85) 200px,
    rgba(0, 0, 0, 0.85) 400px
    );

    body.animation-enabled & {
      animation: moving-strips 4s linear infinite;
    }
  }
  .warning-container {
    position: fixed;
    top: 50%;
    left: 50%;

    width: 800px;
    max-width: 85vw;
    margin: 0 auto;

    transform: translate(-50%, -50%);

    font-family: 'ZCOOL QingKe HuangYou', sans-serif;

    .title {
      margin-bottom: 2vh;

      text-align: center;

      color: yellow;

      font-size: 7vh;
      line-height: 7vh;
    }
    .content {
      margin-bottom: 2vh;

      text-align: center;

      color: white;

      font-size: 4vh;
      line-height: 1.2em;
    }
    .disable {
      text-align: center;

      color: #BBB;

      font-size: 3vh;
      line-height: 3vh;
    }
  }
}


code:
.preloader {
  >.preloader-paragraph, >.preloader-title {
    margin-top: 0.6em;
    margin-bottom: 1.2em;
  }
  >.preloader-title {
    .flash {
      height: 3.5em;
    }
  }
  >.preloader-paragraph {
    .flash {
      height: 1em;
    }
  }
  .flash-container {
    overflow-x: hidden;
    width: 100%;
    margin-bottom: 0.4em;
    >.flash {
      body.animation-enabled & {
        animation: flash 2s linear infinite;
      }
      width: 200%;
      background: linear-gradient(
        90deg,
        var(--key-opacity-014) 0%,
        var(--key-opacity-023) 5%,
        var(--key-opacity-023) 8%,
        var(--key-opacity-014) 13%,
        var(--key-opacity-014) 50%,
        var(--key-opacity-023) 55%,
        var(--key-opacity-023) 58%,
        var(--key-opacity-014) 63%
      );
    }
  }
}

@keyframes flash {
  0% {
    transform: translateX(-50%);
  }
  100% {
    transform: translateX(0);
  }
}


code:
@import (reference) 'common/button.less';
.recommended-chapters {
  li {
    margin-bottom: 0.4em;

    line-height: 1.4em;
    @media (min-width: 1651px) {
      margin-left: -0.8em;
    }
  }
}


code:
.rect-off-mobile() {
  top: -30vmax;
  left: 35vmax;

  width: 90vh;
  height: 200vmax;

  transform: rotate(60deg);
}
.rect {
  position: fixed;
  body.animation-enabled & {
    transition: background-color 0.3s, width 0.5s, height 0.5s, top 0.5s, left 0.5s, transform 0.5s;
  }
  body.layout-off & {
    @media (min-width: 901px) {
      top: -50vmax;
      left: 35vmax;

      width: 75vh;
      height: 200vmax;

      transform: rotate(40deg);
    }
    @media (max-width: 900px) {
      .rect-off-mobile();
    }
  }
  body.layout-main & {
    top: 0;
    left: 0;

    width: 100vw;
    height: 100%;

    transform: rotate(0);
  }
  body.layout-side & {
    @media (min-width: 901px) {
      top: 0;
      left: 50vh;

      width: calc(100vw - 50vh);
      height: 100%;

      transform: rotate(0);
    }
    @media (max-width: 900px) {
      .rect-off-mobile();
    }
  }
}


code:
table {
  position: relative;

  margin: 1em 0;

  border-spacing: 0;

  color: inherit;
}
tbody>tr:nth-child(odd) {
  background-color: var(--key-opacity-01);
}
td, th {
  line-height: 1.6em;
}
th {
  position: sticky;
  top: 0;

  white-space: nowrap;

  background-color: var(--paper-bg-color);

  line-height: 2.0;
}
@media (min-width: 901px) {
  td, th {
    padding: 0 1em;
  }
}
@media (max-width: 900px) {
  table {
    width: 100%;
  }
  td, th {
    padding: 0 0.2em;
  }
}


code:
.tag-div {
  display: inline-block;
  &.active .tag {
    color: var(--comment-color);
    background-color: var(--key);
    box-shadow: rgba(0,0,0,0.24) 0 1.01372px 2.32903px, inset 0 0 0 0 var(--key) !important;

    font-weight: bold;
    .count {
      opacity: 0.5;
      color: var(--comment-color);
    }
    &:hover {
      background-color: var(--key-opacity-07);
    }
  }
  .tag {
    position: relative;

    margin-right: 0.3em;
    padding: 0.3em 0.6em;

    cursor: pointer;
    user-select: none;
    white-space: nowrap;

    border-radius: 0.4em;
    background-color: var(--comment-color);
    box-shadow: rgba(0,0,0,0.24) 0 1.01372px 2.32903px, inset 0 0 0 0 var(--key);

    line-height: 2.2em;
    &.empty {
      opacity: 0.2;
    }
    .text {
      padding-right: 0.5em;
      &.parentheses-ending {
        padding-right: 0;
      }
    }
    .count {
      color: var(--key-opacity-05);
    }
    body.animation-enabled & {
      transition: background-color 0.3s, color 0.3s, box-shadow 0.3s;
    }
    &:hover {
      box-shadow: rgba(0,0,0,0.24) 0 1.01372px 2.32903px, inset 0 0 0 0.1em var(--key);
    }
    &:active {
      color: var(--key-opacity-07);
    }
    // &::before {
    //   position: absolute;
    //   top: 0.4em;
    //   left: 0.4em;

    //   display: block;

    //   width: 0.7em;
    //   height: 0.7em;

    //   content: '';

    //   border-radius: 50%;
    //   background-color: var(--paper-bg-color);
    //   box-shadow: inset rgba(0,0,0,0.24) 0 1.01372px 2.32903px, 0 0 0 0 var(--key);
    //   body.animation-enabled & {
    //     transition: box-shadow 0.3s;
    //   }
    // }
  }
}
.content-container>.content .tags {
  padding-right: calc(0.8em + 0.8vw) !important;
}

.tag-search-chapter {
  .chapter-title {
    margin-top: 0;
    >a:hover {
      text-decoration: underline;
    }
  }
  .match-ratio {
    margin-bottom: 0;

    opacity: 0.7;
  }
}


code:
.tagging-tool {
  .desc-line {
    margin-left: -0.4em;

    border-radius: 0.4em;
    body.animation-enabled & {
      transition: background-color 0.3s;
    }
    &.errored {
      background-color: rgba(237, 67, 55, 0.3);
    }
  }
  .tagging-tool-tag-variant, .tagging-tool-variant {
    padding: 0.2em 0.4em;

    cursor: pointer;
    user-select: none;
  }
  .tagging-tool-tag {
    padding: 0.2em 0 0.2em 0.4em;
    >.tagging-tool-tag-variant {
      margin-right: 0;
      margin-left: 0.2em;
    }
    &.has-available-variant>.no-available-variants {
      display: none;
    }
    >.no-available-variants {
      padding: 0 0.4em;

      cursor: not-allowed;

      color: var(--key-opacity-07);

      font-weight: normal;
    }
  }
  .tagging-tool-variant:hover {
    box-shadow: inset 0 0 0 0.1em var(--key);
  }
  .tagging-tool-variant.selected:hover {
    background-color: var(--key-opacity-07);
  }
  .tagging-tool-tag-variant, .tagging-tool-tag, .tagging-tool-variant {
    margin: 0 0.2em;

    border-radius: 0.4em;
    background-color: var(--comment-color);
    box-shadow: inset 0 0 0 0 var(--key);

    font-weight: bold;
    body.animation-enabled & {
      transition: background-color 0.3s, color 0.3s, box-shadow 0.3s;
    }
    &.errored {
      color: white !important;
      background-color: #ED4337 !important;
    }
    &.selected {
      color: var(--comment-color);
      background-color: var(--key);
      box-shadow: inset 0 0 0 0 var(--key) !important;
      &.hovered {
        background-color: var(--key-opacity-07);
      }
    }
    &.hovered {
      box-shadow: inset 0 0 0 0.1em var(--key);
    }
    >input {
      position: relative;
      top: 0.1em;

      margin: 0 0.5em 0 0;

      cursor: pointer;
    }
  }
  p.tag-title {
    margin-bottom: 0;
  }
  >ul {
    margin-top: 0;
  }
}


code:
.title-container {
  position: fixed;
  top: 5vh;
  left: 5vh;

  transform: translateX(0);

  opacity: 1;
  color: var(--key);
  &.hidden {
    transform: translateX(-5vh);
    pointer-events: none;

    opacity: 0;
  }
  body.animation-enabled & {
    transition: transform 0.3s, opacity 0.3s, color 0.3s;
  }
  @media (max-width: 900px) {
    & {
      left: 50%;

      width: 38.3vh;

      transform: translateX(-50%);
      white-space: nowrap;
    }
  }
  .english-title {
    font-family: 'ZCOOL QingKe HuangYou', cursive;
    font-size: 5.2vh;
    line-height: 5.2vh;
  }
  .title {
    font-family: 'ZCOOL QingKe HuangYou', cursive;
    font-size: 10vh;
    line-height: 10vh;
  }
  .build-author-container {
    display: flex;

    padding-right: 0.5vh;
    padding-left: 0.5vh;
  }
  .build-author-container .build {
    flex: 1;

    padding-top: 0.2vh;

    text-align: left;

    font-family: 'ZCOOL QingKe HuangYou', cursive;
    font-size: 2.6vh;
  }
  .build-author-container .author {
    flex: 1;

    text-align: right;

    font-size: 2vh;
  }
}


code:
@navbar-height: 2.6rem;

@material-content-regular-padding: 50px;

@content-block-width: 820px;
@content-width: 720px;
@block-spacing: 2vw;
@side-block-width: 380px;
@side-block-padding: 40px;


code:
.visit-count-holder {
  >a {
    display: block;
    overflow-x: visible;

    margin: 0.2em 0;
    padding: 0.15em 0.3em;

    cursor: pointer;
    white-space: nowrap;

    border-top-right-radius: 0.4em;
    border-bottom-right-radius: 0.4em;
    background-color: var(--key-opacity-01);

    body.animation-enabled & {
      transition: background-color 0.12s;
    }
    &:hover {
      background-color: var(--key-opacity-015) !important;
    }
    &:active {
      background-color: var(--key-opacity-01) !important;
    }
  }
  &:hover>a {
    background-color: var(--key-opacity-007);
  }
}


code:
@import (reference) 'common/button.less';

.wtcd-save-button-list {
  >div:extend(#common.button all) {
    margin-top: 2vh;
    &.save {
      display: flex;

      text-align: left;
      >.id {
        margin-right: 0.5em;
        margin-left: 0.1em;

        font-size: 2.6em;
        font-weight: bold;
        &.small {
          font-size: 2.2em;
        }
      }
      >.info {
        >.state-desc {
          margin-top: 0.4em;
        }
        >.date {
          margin-top: 0.2em;
          margin-bottom: 0.2em;

          opacity: 0.7;

          font-size: 0.8em;
        }
      }
    }
  }
}


code:
.content-container>.content .content-block.wtcup-vote {
  .stars-container {
    display: flex;
    justify-content: center;

    margin-bottom: 1em;
    .choice {
      cursor: pointer;
      user-select: none;

      font-family: 'Material Icons';
      font-size: 3em;
      &.clear {
        margin-right: 0.3em;
        &.dimmed {
          cursor: not-allowed;
        }
      }
      &.star {
        padding-right: 0.05em;
        padding-left: 0.05em;
      }
      &.dimmed {
        opacity: 0.3;
      }
      &.less-dimmed {
        opacity: 0.6;
      }
    }
  }
}


code:
# 使用的 z-index
- rect：1
- content container：2
- modal：3
- hint：4
- NSFW：5


code:
function appendChildren($element: HTMLElement, children: any) {
  if (children === undefined || children === null || children === false) {
    return;
  }
  if (Array.isArray(children)) {
    children.forEach(child => appendChildren($element, child));
  } else {
    $element.append(children);
  }
}

export function $e(
  tag: string,
  attributes: { [key: string]: any } | null,
  ...children: Array<any>
) {
  const $element = document.createElement(tag);

  if (attributes !== null) {
    for (const [key, value] of Object.entries(attributes)) {
      if (value === null) {
        continue;
      }
      if (key === 'style') {
        for (const [cssKey, cssValue] of Object.entries(value)) {
          ($element.style as any)[cssKey] = cssValue;
        }
      } else {
        if (Array.isArray(value)) {
          ($element as any)[key] = value.join(' ');
        } else {
          ($element as any)[key] = value;
        }
      }
    }
  }
  appendChildren($element, children);
  return $element;
}


code:
import { materialDarkColors } from './constant/materialDarkColors';
import { developerMode } from './data/settings';
import { stringHash } from './util/stringHash';

export class DebugLogger {
  private prefix: string;
  private css: string;
  public constructor(
    name: string,
  ) {
    this.prefix = '%c' + name;
    this.css = `background-color: #` +
      materialDarkColors[
        Math.abs(stringHash(name)) % materialDarkColors.length
      ] +
      '; border-radius: 0.3em; padding: 0 0.3em; color: white';
  }
  public log(...stuff: any) {
    if (!developerMode.getValue()) {
      return;
    }
    console.info(this.prefix, this.css, ...stuff);
  }
  public warn(...stuff: any) {
    console.warn(this.prefix, this.css, ...stuff);
  }
  public error(...stuff: any) {
    console.error(this.prefix, this.css, ...stuff);
  }
}


code:
type Listener<T> = (arg: T, listener: Listener<T>) => void;

export class Event<T = void> {
  private listeners: Set<Listener<T>> | null = null;
  private onceListeners: Set<Listener<T>> | null = null;

  private isEmitting: boolean = false;
  private queue: Array<() => void> = [];

  private runWhenNotEmitting(fn: () => void) {
    if (this.isEmitting) {
      this.queue.push(fn);
    } else {
      fn();
    }
  }

  public on(listener: Listener<T>) {
    this.runWhenNotEmitting(() => {
      if (this.listeners === null) {
        this.listeners = new Set();
      }
      this.listeners.add(listener);
    });
    return listener;
  }
  public onUntil(listener: Listener<T>, event: Event<any>) {
    this.runWhenNotEmitting(() => {
      this.on(listener);
      event.once(() => this.off(listener));
    });
  }
  public off(listener: Listener<T>) {
    this.runWhenNotEmitting(() => {
      if (this.listeners !== null) {
        this.listeners.delete(listener);
      }
      if (this.onceListeners !== null) {
        this.onceListeners.delete(listener);
      }
    });
  }
  public once(onceListener: (arg: T) => void) {
    this.runWhenNotEmitting(() => {
      if (this.onceListeners === null) {
        this.onceListeners = new Set();
      }
      this.onceListeners.add(onceListener);
    });
    return onceListener;
  }
  public onceUntil(listener: (arg: T) => void, event: Event<any>) {
    this.runWhenNotEmitting(() => {
      this.once(listener);
      event.once(() => this.off(listener));
    });
  }
  public expect(filter?: (arg: T) => boolean): Promise<T> {
    if (this.isEmitting) {
      return new Promise(resolve => {
        this.queue.push(() => {
          this.expect(filter).then(resolve);
        });
      });
    }
    if (filter === undefined) {
      return new Promise(resolve => this.once(resolve));
    }
    return new Promise(resolve => {
      const listener = this.on(arg => {
        if (!filter(arg)) {
          return;
        }
        this.off(listener);
        resolve(arg);
      });
    });
  }
  public emit(arg: T) {
    this.runWhenNotEmitting(() => {
      this.isEmitting = true;
      if (this.listeners !== null) {
        this.listeners.forEach(listener => listener(arg, listener));
      }
      if (this.onceListeners !== null) {
        this.onceListeners.forEach(onceListener => onceListener(arg, onceListener));
        this.onceListeners = new Set();
      }
      this.isEmitting = false;
      this.queue.forEach(task => task());
      this.queue.length = 0;
    });
  }
}


code:
import { Layout, setLayout } from './control/layoutControl';
import { DebugLogger } from './DebugLogger';
import { h } from './hs';
import { forceReflow } from './util/DOM';

export enum ItemDecoration {
  SELECTABLE,
  BACK,
  ICON_FOLDER,
  ICON_LINK,
  ICON_EQUALIZER,
  ICON_FILE,
  ICON_GAME,
  ICON_NOTIFICATION,
  ICON_LIST,
  ICON_CALENDER,
  ICON_HISTORY,
  ICON_CLEAR,
  ICON_PERSON,
  ICON_TAG,
}

export enum ItemLocation {
  BEFORE,
  AFTER,
}

type ItemOptions = {
  location?: ItemLocation,
  hidden?: boolean;
} & ({
  button?: false;
} | {
  button: true;
  link?: string;
  decoration?: ItemDecoration;
});

function createSpan(text: string, ...classNames: Array<string>) {
  const $span = document.createElement('span');
  $span.innerText = text;
  $span.classList.add(...classNames);
  return $span;
}

export class ItemHandle {
  private $prependSpan: HTMLSpanElement | null = null;
  private $appendSpan: HTMLSpanElement | null = null;
  public constructor(
    private menu: Menu,
    public $flexElement: HTMLDivElement,
    public $innerElement: HTMLDivElement | HTMLAnchorElement,
  ) { }
  public setSelected(selected: boolean) {
    this.$innerElement.classList.toggle('selected', selected);
    return this;
  }
  public onClick(handler: (element: ItemHandle) => void) {
    this.$innerElement.addEventListener('click', () => {
      handler(this);
    });
    return this;
  }
  public setInnerText(innerText: string) {
    this.$innerElement.innerText = innerText;
    return this;
  }
  public addClass(className: string) {
    this.$innerElement.classList.add(className);
    return this;
  }
  public removeClass(className: string) {
    this.$innerElement.classList.remove(className);
    return this;
  }
  public prepend(text: string, className?: string) {
    if (this.$prependSpan === null) {
      this.$prependSpan = createSpan('', 'prepend');
      this.$innerElement.prepend(this.$prependSpan);
    }
    const $span = createSpan(text, 'item-side');
    if (className !== undefined) {
      $span.classList.add(className);
    }
    this.$prependSpan.prepend($span);
    return $span;
  }
  public append(text: string, className?: string) {
    if (this.$appendSpan === null) {
      this.$appendSpan = createSpan('', 'append');
      this.$innerElement.appendChild(this.$appendSpan);
    }
    const $span = createSpan(text, 'item-side');
    if (className !== undefined) {
      $span.classList.add(className);
    }
    this.$appendSpan.appendChild($span);
    return $span;
  }
  public remove() {
    this.$flexElement.remove();
  }
}

export class Menu {
  public readonly subMenus: Map<string, {
    name: string,
    factory: () => Menu,
  }> = new Map();
  protected container: HTMLDivElement;
  private debugLogger: DebugLogger;
  public constructor(
    public readonly urlBase: string,
    public readonly layout: Layout = Layout.OFF,
  ) {
    this.debugLogger = new DebugLogger(`Menu (${urlBase})`);
    this.container = h('.menu.hidden');
    document.body.appendChild(this.container);
    // if (this.fullPath.length >= 1) {
    //   const path = document.createElement('div');
    //   path.classList.add('path');
    //   path.innerText = this.fullPath.join(' > ');
    //   this.container.appendChild(path);
    // }
    // if (parent !== null) {
    //   this.addItem('返回', { button: true, decoration: ItemDecoration.BACK, unclearable: true })
    //     .linkTo(parent);
    // }
  }

  public destroy() {
    this.container.classList.add('hidden');
    setTimeout(() => this.container.remove(), 1000);
  }

  public hide() {
    this.container.classList.add('hidden');
  }

  public show() {
    setLayout(this.layout);
    forceReflow(this.container);
    this.container.classList.remove('hidden');
  }

  public addItem(title: string, options: ItemOptions = {}): ItemHandle {
    let $innerElement: HTMLDivElement | HTMLAnchorElement;
    if (options.button && options.link !== undefined) {
      $innerElement = document.createElement('a');
      $innerElement.href = options.link;
      $innerElement.rel = 'noopener noreferrer';
      if (!options.link.startsWith('#')) {
        $innerElement.target = '_blank';
      }
    } else {
      $innerElement = document.createElement('div');
    }
    $innerElement.innerText = title;
    if (options.hidden) {
      $innerElement.classList.add('display-none');
    }
    if (options.button) {
      $innerElement.classList.add('button');
      switch (options.decoration) {
        case ItemDecoration.BACK:
          $innerElement.classList.add('back');
          break;
        case ItemDecoration.SELECTABLE:
          $innerElement.classList.add('selectable');
          break;
        case ItemDecoration.ICON_FOLDER:
          $innerElement.classList.add('icon', 'folder');
          break;
        case ItemDecoration.ICON_LINK:
          $innerElement.classList.add('icon', 'link');
          break;
        case ItemDecoration.ICON_EQUALIZER:
          $innerElement.classList.add('icon', 'equalizer');
          break;
        case ItemDecoration.ICON_FILE:
          $innerElement.classList.add('icon', 'file');
          break;
        case ItemDecoration.ICON_GAME:
          $innerElement.classList.add('icon', 'game');
          break;
        case ItemDecoration.ICON_NOTIFICATION:
          $innerElement.classList.add('icon', 'notification');
          break;
        case ItemDecoration.ICON_LIST:
          $innerElement.classList.add('icon', 'list');
          break;
        case ItemDecoration.ICON_CALENDER:
          $innerElement.classList.add('icon', 'calender');
          break;
        case ItemDecoration.ICON_HISTORY:
          $innerElement.classList.add('icon', 'history');
          break;
        case ItemDecoration.ICON_CLEAR:
          $innerElement.classList.add('icon', 'clear');
          break;
        case ItemDecoration.ICON_PERSON:
          $innerElement.classList.add('icon', 'person');
          break;
        case ItemDecoration.ICON_TAG:
          $innerElement.classList.add('icon', 'icon-tag');
          break;
      }
    }
    const $flexElement = h('.flex') as HTMLDivElement;
    $flexElement.appendChild($innerElement);
    if (options.location === ItemLocation.BEFORE) {
      this.container.prepend($flexElement);
    } else {
      this.container.appendChild($flexElement);
    }

    return new ItemHandle(this, $flexElement, $innerElement);
  }
  protected buildSubMenu<TAdditionalArgs extends Array<unknown>>(
    name: string,
    MenuConstructor: (new (urlBase: string, ...args: TAdditionalArgs) => Menu),
    ...args: TAdditionalArgs
  ) {
    return new SubMenuBuilder(this, name, urlSegment => new MenuConstructor(`${this.urlBase}/${urlSegment}`, ...args));
  }
  public registerSubMenu(name: string, urlSegment: string, menuFactory: () => Menu) {
    if (this.subMenus.has(urlSegment)) {
      this.debugLogger.error(`Duplicated url segment: ${urlSegment}.`);
    }
    this.subMenus.set(urlSegment, {
      name,
      factory: menuFactory,
    });
  }
}

class SubMenuBuilder {
  /** Used in URL */
  private urlSegment: string;
  private displayName: string;
  private decoration: ItemDecoration | undefined = undefined;
  private hidden = false;
  public constructor(
    private readonly parentMenu: Menu,
    /** Displayed in the parent menu */
    private readonly name: string,
    private readonly subMenuFactory: (urlSegment: string) => Menu,
  ) {
    this.urlSegment = name;
    this.displayName = name;
  }
  /**
   * Used for constructing menu url
   */
  public setUrlSegment(urlSegment: string) {
    this.urlSegment = urlSegment;
    return this;
  }
  public setDecoration(decoration: ItemDecoration) {
    this.decoration = decoration;
    return this;
  }
  /**
   * Shown in the menu (initially); Defaults to name
   */
  public setDisplayName(displayName: string) {
    this.displayName = displayName;
    return this;
  }
  public setHidden(hidden: boolean = true) {
    this.hidden = hidden;
    return this;
  }
  public build() {
    this.parentMenu.registerSubMenu(
      this.name,
      this.urlSegment,
      () => this.subMenuFactory(this.urlSegment),
    );
    return this.parentMenu.addItem(this.displayName, {
      button: true,
      link: `${this.parentMenu.urlBase}/${this.urlSegment}`,
      decoration: this.decoration,
      hidden: this.hidden,
    });
  }
}


code:
export const PREVIOUS_CHAPTER = '上一章';
export const GO_TO_MENU = '返回';
export const NEXT_CHAPTER = '下一章';

export const CHAPTER_LOADING = '章节加载中...';
export const CHAPTER_FAILED = '章节加载失败，请检查网络连接。';

export const BUILD_FAILED_TITLE = '构建失败';
export const BUILD_FAILED_DESC = '《可穿戴科技》的网页构建脚本在构建过程中发生了可以恢复的异常。详细内容请参见控制台输出。';
export const BUILD_FAILED_OK = '我知道了';

export const BROKEN_LINK_TITLE = '损坏的链接';
export const BROKEN_LINK_DESC = '对不起，您提供的地址无法被正确解析，所以您现在回到了《可穿戴科技》的首页。';
export const BROKEN_LINK_OK = '我知道了';

export const COMMENTS_SECTION = '评论区';
export const COMMENTS_LOADING = '评论加载中...';
export const COMMENTS_UNAVAILABLE = '本文评论不可用。';
export const COMMENTS_SEND = '+ 发表评论';
export const COMMENTS_LOADED = '以下为本章节的评论区。';
export const COMMENTS_FAILED = '评论加载失败，请检查网络连接。';
export const COMMENTS_RECENT_SECTION = '最新评论';
export const COMMENTS_RECENT_LOADED = '以下是最新的五十条评论。';
export const COMMENTS_MENTION_SECTION = '最新回复';
export const COMMENTS_MENTION_LOADED = '以下是最新回复了您的评论。';
export const COMMENTS_MENTION_NO_TOKEN_TITLE = '最新回复不可用';
export const COMMENTS_MENTION_NO_TOKEN_DESC = '请至少发送了一条评论后再来查看。';
export const COMMENTS_MENTION_REPLIED_TITLE = '回复成功';
export const COMMENTS_MENTION_REPLIED_OK = '完成';
export const COMMENTS_SUBMIT_BY = '由$发表于 ';
export const COMMENTS_MODIFIED_DATE = '（最后修改于$）';
export const COMMENTS_SENDING = '正在发表评论...';
export const COMMENTS_DELETING = '正在删除评论...';
export const COMMENTS_SEND_DISPLAY_NAME_0 = '将以';
export const COMMENTS_SEND_DISPLAY_NAME_1 = '的身份发表评论。';
export const COMMENTS_SEND_DISPLAY_NAME_EDIT = '点此修改';
export const COMMENTS_DELETE = '删除评论';
export const COMMENTS_REPLY = '回复';
export const COMMENTS_DELETE_CONFIRMATION = '确定要删除这条评论吗？';
export const COMMENTS_SEND_TITLE = '发表评论';
export const COMMENTS_SEND_CONFIRM = '发表';
export const COMMENTS_LOSE_EDITED_TITLE = '是否放弃编写的内容？';
export const COMMENTS_LOSE_EDITED_CONFIRM = '放弃编写的内容';
export const COMMENTS_LOSE_EDITED_CANCEL = '继续编辑';
export const COMMENTS_SEND_HINT = '请填写评论内容。';
export const COMMENTS_SEND_DISPLAY_NAME_PREFIX = '昵称（必填）：';
export const COMMENTS_SEND_EMAIL_INPUT_PREFIX = '邮箱（可选）：';

export const VISIT_COUNT_TITLE = '访问量统计';
export const VISIT_COUNT_DESC_0 = '访问量统计信息由《可穿戴科技》访问统计系统提供。《可穿戴科技》访问统计系统是专门为本小说编写的高度重视隐私保护的，不记录任何个人信息的数据统计服务。其隐私政策可以在';
export const VISIT_COUNT_DESC_1 = '这里';
export const VISIT_COUNT_DESC_2 = '找到。';

export const VISIT_COUNT_LOADING = '访问量加载中...';
export const VISIT_COUNT_FAILED = '访问量加载失败，请检查网络连接。';
export const VISIT_COUNT_LOAD_MORE = '加载更多';
export const VISIT_COUNT_LOAD_MORE_LOADING = '加载中...';
export const VISIT_COUNT_LOAD_MORE_FAILED = '加载失败，点此重试';
export const VISIT_COUNT_TIME_FRAME_ALL = '所有';
export const VISIT_COUNT_TIME_FRAME_HOUR = '1 小时内';
export const VISIT_COUNT_TIME_FRAME_DAY = '24 小时内';
export const VISIT_COUNT_TIME_FRAME_WEEK = '7 天内';
export const VISIT_COUNT_TIME_FRAME_MONTH = '30 天内';
export const VISIT_COUNT_TIME_FRAME_YEAR = '365 天内';
export const VISIT_COUNT_DISPLAYING = '正在显示$的访问量：';
export const VISIT_COUNT_TIMES = ' 次';

export const NO_BLOCKED_USERS = '没有用户的评论被屏蔽';
export const CLICK_TO_UNBLOCK = '(点击用户名以解除屏蔽)';

export const WTCD_GAME_RESTART = '重置游戏';
export const WTCD_GAME_RESTART_TITLE = '是否重置游戏种子';
export const WTCD_GAME_RESTART_DESC = '游戏种子会决定游戏的随机因素。';
export const WTCD_GAME_RESTART_ALL_DESC = '若选择【重置游戏种子与进度】，那么新游戏中的所有随机因素和本游戏不一致。';
export const WTCD_GAME_RESTART_DECISION_ONLY_DESC = '若选择【仅重置进度】，那么新游戏中的随机因素将与本游戏完全一致。';
export const WTCD_GAME_RESTART_ALL = '重置游戏种子与进度';
export const WTCD_GAME_RESTART_DECISION_ONLY = '仅重置进度';
export const WTCD_GAME_RESTART_CANCEL = '不重置任何内容';
export const WTCD_GAME_RESTART_OK = '游戏重置成功';
export const WTCD_GAME_SAVE = '存储';
export const WTCD_GAME_SAVE_TITLE = '请选择要存储的位置';
export const WTCD_GAME_SAVE_CANCEL = '取消';
export const WTCD_GAME_SAVE_NEW = '- 新存档 -';
export const WTCD_GAME_SAVE_OVERWRITE_TITLE = '是否覆盖？';
export const WTCD_GAME_SAVE_OVERWRITE_CONFIRM = '确认覆盖';
export const WTCD_GAME_SAVE_OVERWRITE_CANCEL = '取消';
export const WTCD_GAME_SAVE_OK = '存储成功';
export const WTCD_GAME_LOAD = '读取';
export const WTCD_GAME_LOAD_TITLE = '请选择要读取的存档';
export const WTCD_GAME_LOAD_CANCEL = '取消';
export const WTCD_GAME_LOAD_QUICK = '快';
export const WTCD_GAME_LOAD_OK = '读取成功';
export const WTCD_GAME_QUICK_SAVE = '快速存储';
export const WTCD_GAME_QUICK_SAVE_OK = '快速存储成功';
export const WTCD_GAME_QUICK_LOAD = '快速读取';
export const WTCD_GAME_QUICK_LOAD_OK = '快速读取成功';
export const WTCD_GAME_QUICK_LOAD_NOT_EXIST = '没有可用的快速存档，请先使用【快速存储】来创建快速存档。';
export const WTCD_GAME_QUICK_LOAD_CONFIRM_TITLE = '是否快速读取？';
export const WTCD_GAME_QUICK_LOAD_CONFIRM_DESC = '这会丢失当前未保存的数据。（可在【设置】中禁用此确认）';
export const WTCD_GAME_QUICK_LOAD_CONFIRM_CONFIRM = '确认读取';
export const WTCD_GAME_QUICK_LOAD_CONFIRM_CANCEL = '取消';
export const WTCD_GAME_NO_DESC = '无描述文本';

export const WTCD_ERROR_COMPILE_TITLE = 'WTCD 编译失败';
export const WTCD_ERROR_COMPILE_DESC = '该 WTCD 文档在编译时发生了错误。请检查是否有语法错误或是其他基本错误。';
export const WTCD_ERROR_RUNTIME_TITLE = 'WTCD 运行时错误';
export const WTCD_ERROR_RUNTIME_DESC = '该 WTCD 文档在运行时发生了错误。请检查是否有逻辑错误。';
export const WTCD_ERROR_INTERNAL_TITLE = 'WTCD 内部错误';
export const WTCD_ERROR_INTERNAL_DESC = 'WTCD 解释器在解释执行该 WTCD 文档时崩溃了。请务必告诉琳你做了什么好让她来修。';
export const WTCD_ERROR_MESSAGE = '错误信息：';
export const WTCD_ERROR_WTCD_STACK_TITLE = 'WTCD 调用栈';
export const WTCD_ERROR_WTCD_STACK_DESC = 'WTCD 调用栈记录了在错误发生时 WTCD 的解释器状态。这可以帮助理解错误发生的上下文。';
export const WTCD_ERROR_INTERNAL_STACK_TITLE = '内部调用栈';
export const WTCD_ERROR_INTERNAL_STACK_DESC = '内部调用栈记录了出现该错误时编译器或是解释器的状态。请注意内部调用栈通常只在调试 WTCD 编译器或是解释器时有用。内部调用栈通常对调试 WTCD 文档没有作用。';

export const WTCD_CANVAS_LOADING = '资源加载中... 取决于网络情况，这可能需要 1 秒 ~ 10 分钟。';

export const GENERIC_SUCCESS_TITLE = '成功';
export const GENERIC_SUCCESS_DESC = '操作已完成。';
export const GENERIC_ERROR_TITLE = '错误';
export const GENERIC_ERROR_DESC = '发生了错误。';
export const GENERIC_LOADING_TITLE = '请稍后';
export const GENERIC_LOADING_DESC = '正在加载...';
export const GENERIC_HINT_TITLE = '提示';
export const GENERIC_CONFIRM = '确认';
export const GENERIC_CANCEL = '取消';
export const GENERIC_CLOSE = '关闭';
export const GENERIC_INTERNET_ERROR = '加载失败，请检查网络连接。';

export const BACKEND_ERROR_NAME_DUPLICATED = '昵称已存在。如果您在此之前使用别的设备在《可穿戴科技》评论过，请将之前评论时自动生成的身份令牌复制到“设置” > “评论身份管理” > “身份令牌”。';
export const BACKEND_ERROR_EMAIL_DUPLICATED = '邮箱已存在。如果您在此之前使用别的设备在《可穿戴科技》评论过，请将之前评论时自动生成的身份令牌复制到“设置” > “评论身份管理” > “身份令牌”。';
export const BACKEND_ERROR_NAME_TOO_LONG = '昵称过长。';
export const BACKEND_ERROR_EMAIL_TOO_LONG = '邮箱过长。';
export const BACKEND_ERROR_EMAIL_INVALID = '邮箱格式不正确。《可穿戴科技》评论系统目前只接受常见的邮箱格式。如果您觉得您的邮箱格式并不罕见，请联系琳。';
export const BACKEND_ERROR_COMMENT_TOO_LONG = '评论过长。';
export const BACKEND_ERROR_TOKEN_INVALID = '令牌无效。';
export const BACKEND_ERROR_NAME_TOO_SHORT = '昵称过短。';
export const BACKEND_ERROR_COMMENT_TOO_SHORT = '评论过短。';
export const BACKEND_ERROR_NAME_INVALID = '昵称格式不正确。请确保昵称中不包含控制字符。';
export const BACKEND_ERROR_UNKNOWN = '发生了未知错误，请联系琳。';

export const USER_TOKEN_CHANGE_EMPTY = '请输入身份令牌。';
export const USER_TOKEN_CHANGE_INVALID = '身份令牌无效，请再次检查您输入的内容是否正确。';
export const USER_TOKEN_CHANGE_CHECKING = '正在确认您的身份令牌...';
export const USER_TOKEN_CHANGE_SUCCESS = '身份令牌设置成功';
export const USER_TOKEN_CHANGE_TITLE = '修改身份令牌';
export const USER_TOKEN_CHANGE_DESC = '这是您的身份令牌。\n使用身份令牌可以在《可穿戴科技》发表评论。\n当您第一次发表评论时，系统就会自动生成一个身份令牌。而后续发表的评论则会使用同一个令牌。\n您可以通过将这个令牌复制到别的电脑上来实现使用多台设备用一个身份回复。';
export const USER_TOKEN_CHANGE_INPUT_LABEL = '令牌：';
export const USER_TOKEN_DOES_NOT_EXIST = '您目前不持有身份令牌。请至少评论一次后再尝试。';

export const USER_UPDATE_PROFILE_ERROR_NOT_INITIALIZED = '身份信息初始化失败，请刷新页面重试。';
export const USER_UPDATE_PROFILE_TITLE = '修改身份信息';
export const USER_UPDATE_PROFILE_DESC = '您可以在此修改您在使用评论系统时的身份信息。';
export const USER_UPDATE_PROFILE_HINT_USER_NAME = '用户名决定他人如何在评论中 @ 您，不可修改。';
export const USER_UPDATE_PROFILE_HINT_DISPLAY_NAME = '昵称是显示在您发表的每一条评论上的身份标识。';
export const USER_UPDATE_PROFILE_HINT_EMAIL_0 = '邮箱用于加载头像；若要设置头像，请';
export const USER_UPDATE_PROFILE_HINT_EMAIL_GRAVATAR_LINK = '点此转到 Gravatar';
export const USER_UPDATE_PROFILE_HINT_EMAIL_1 = '，使用相同邮箱注册账号并上传头像；您的邮箱永远不会公开给其他用户。';
export const USER_UPDATE_PROFILE_USER_NAME_INPUT_LABEL = '用户名：';
export const USER_UPDATE_PROFILE_DISPLAY_NAME_INPUT_LABEL = '昵称：';
export const USER_UPDATE_PROFILE_EMAIL_INPUT_LABEL = '邮箱：';
export const USER_UPDATE_PROFILE_DISPLAY_NAME_EMPTY = '昵称不能为空。';
export const USER_UPDATE_PROFILE_LOADING = '正在更新您的信息...';
export const USER_UPDATE_PROFILE_SUCCESS = '信息更新成功！';

export const AUTHOR_PAGE_WORKS = '作品列表';
export const AUTHOR_PAGE_WORKS_DESC = '以下为$参与创作的作品。';
export const AUTHOR_PAGE_AS = '作为';
export const AUTHOR_PAGE_LINK = '链接';

export const MIRROR_TITLE = '镜像站选择';
export const MIRROR_DESC_0_NO_TOKEN = '请选择您要使用的镜像站。您的阅读历史和阅读器设置将不会被转移。';
export const MIRROR_DESC_0 = '请选择您要使用的镜像站。您的评论身份将会被自动转移，但是阅读历史和阅读器设置将不会被转移。（转移后，您依然可以使用同样的身份在之前使用的站点评论。）';
export const MIRROR_DESC_1 = '所有镜像站都是读者无偿提供的。如果好用，记得和提供者道一声谢哦。';
export const MIRROR_URL = '网址：';
export const MIRROR_PROVIDED_BY = '提供者：';
export const MIRROR_TECHNOLOGY = '技术：';
export const MIRROR_LANDING_INVALID_REFERRAL = '因为 referrer 错误，您的评论身份信息转移失败。';
export const MIRROR_LANDING_SUCCESS_HINT = '评论身份转移成功';
export const MIRROR_LANDING_CONFLICT_TITLE = '评论身份冲突';
export const MIRROR_LANDING_CONFLICT_DESC = '您刚刚使用的站点请求修改本站的评论身份。但是您之前已经在本站评论过，且使用了不同的评论身份，请问您要？';
export const MIRROR_LANDING_CONFLICT_OVERWRITE = '用刚刚使用的站点的评论身份覆盖本站的评论身份';
export const MIRROR_LANDING_CONFLICT_KEEP = '保留本站的评论身份';
export const MIRROR_HINT_0 = '当前正在使用由$提供的镜像站，';
export const MIRROR_HINT_1 = '。';
export const MIRROR_HINT_CLICK_TO_CHANGE = '点此更换';


code:
export interface MirrorSite {
  name: string;
  origin: string;
  provider: string;
  technology: string;
}

export const mirrorSites: Array<MirrorSite> = [
  {
    name: 'wt.bgme.me',
    origin: 'https://wt.bgme.me',
    provider: 'Shrink',
    technology: '自行构建',
  },
  {
    name: 'rbq.desi',
    origin: 'https://rbq.desi',
    provider: 'kookxiang',
    technology: '代理',
  },
  {
    name: 'wt.0w0.bid',
    origin: 'https://wt.0w0.bid',
    provider: '立音喵',
    technology: 'Cloudflare Workers',
  },
  {
    name: 'wt.umwings.com',
    origin: 'https://wt.umwings.com',
    provider: 'C86',
    technology: '同步构建产物',
  },
];

export const mirrorSitesPlusMainSite: Array<MirrorSite> = [{
  name: 'wt.tepis.me（主站）',
  origin: 'https://wt.tepis.me',
  provider: '琳 缇佩斯',
  technology: 'GitLab Pages',
}, ...mirrorSites];


code:
// ! NOTE: Redirects are only applied after the path fails to match

export const redirects: Array<(input: string) => string> = [
  input => input.replace(/^chapter\/人设卡\/(.*)$/, 'chapter/主线/人设卡/$1'),
  input => input.replace(/^chapter\/支线\/(.*)$/, 'chapter/$1'),
  input => input.replace(/^menu\/章节选择\/所有章节\/支线\/(.*)$/, 'menu/章节选择/所有章节/$1'),
  input => {
    if (input.startsWith('chapter/') && input.endsWith('.html') && !input.endsWith('第-1-章.html')) {
      return input.replace(/^(chapter\/.*)\.html$/, '$1/第-1-章.html');
    } else {
      return input;
    }
  },
];


code:
export const rolePriorities: Map<string, number> = new Map();

const rolesInOrder = [
  '作者',
  '画师',
  '编辑',
];

rolesInOrder.reverse().forEach((role, index) => rolePriorities.set(role, index));

export function getRolePriority(role: string) {
  return rolePriorities.get(role) ?? -1;
}


code:
export const stylePreviewArticle = `
已移除

已移除
`;


code:
export type Tag = string;
export type TagInfo = {
  tag: Tag,
  definition: string,
  details?: Array<string>,
  prerequisite: Array<Tag | [Tag, string]>,
};


code:
export const thanks: Array<{ name: string, link?: string }> = [
  { name: '立音喵' },
  { name: 'lgd_小翅膀' },
  { name: '青葉' },
  { name: 'kn' },
  { name: 'F74nk', link: 'https://t.me/F74nk_K' },
  { name: '杨佳文' },
  { name: '不知名的N姓人士就好' },
  { name: '某不愿透露姓名的N性？' },
  { name: '神楽坂 萌绫' },
  { name: 'Butby' },
  { name: 'NekoCaffeine' },
  { name: 'RainSlide' },
  { name: 'czp', link: 'https://www.hiczp.com' },
  { name: 'kookxiang' },
  { name: '櫻川 紗良' },
  { name: 'Skimige' },
  { name: 'TExL', link: 'http://texas.penguin-logistics.cn' },
  { name: '路人乙' },
  { name: 'pokemonchw', link: 'https://github.com/pokemonchw' },
  { name: '帕蒂卡', link: 'https://github.com/Patika-ailemait' },
  { name: '零件', link: 'https://nekosc.com' },
  { name: '幻梦', link: 'https://t.me/HuanmengQwQ' },
  { name: 'acted咕咕喵', link: 'https://acted.gitlab.io/h3' },
  { name: '重水时雨', link: 'https://t.me/boatmasteronD2O' },
  { name: '神楽坂 紫' },
  { name: 'Runian Lee', link: 'https://t.me/Runian' },
  { name: '琥珀' },
  { name: '为霜' },
  { name: '冰蓮音' },
  { name: 'Testingdoll01' },
  { name: 'VV', link: 'https://www.pixiv.net/users/58170013' },
  { name: '城岭 樱' },
  { name: '熵' },
  { name: '0 级骨钉' },
  { name: '夏克斯' },
  { name: '王木木' },
  { name: 'C86', link: 'https://c86.moe' },
  { name: '炽羽渡尘01' },
  { name: '47AD' },
  { name: '禾裱禾裱' },
  { name: '灵翼' },
  { name: '铱则' },
].sort(() => Math.random() - 0.5);