docs: update WishFulfilled knowledge base
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const root = 'D:/AIcoding/WishFulfilled/知识库/under-anything';
|
||||
const wiki = `${root}/wishfulfilled-wiki`;
|
||||
const reqDir = `${wiki}/05_需求文档`;
|
||||
const graphPaths = [
|
||||
`${wiki}/.understand-anything/knowledge-graph.json`,
|
||||
`${root}/wishfulfilled-dashboard/knowledge-graph.json`,
|
||||
@@ -13,14 +13,73 @@ const metaPaths = [
|
||||
`${root}/wishfulfilled-dashboard/meta.json`,
|
||||
];
|
||||
|
||||
const files = fs.readdirSync(reqDir)
|
||||
.filter((name) => /\.(md|html?)$/i.test(name))
|
||||
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
|
||||
const directoryConfigs = [
|
||||
{
|
||||
dir: `${wiki}/05_需求文档`,
|
||||
relDir: '05_需求文档',
|
||||
layerId: 'layer-requirements',
|
||||
flowId: 'flow:layer-requirements',
|
||||
layerName: '需求文档',
|
||||
layerDescription: '所有正式需求、业务规则、需求变更和需求索引。点击本层可查看全部需求文档并检索。',
|
||||
defaultTags: ['05_需求文档', '需求文档'],
|
||||
category: 'layer-requirements',
|
||||
fallbackSummary: '需求文档。',
|
||||
},
|
||||
{
|
||||
dir: `${wiki}/07_技术文档`,
|
||||
relDir: '07_技术文档',
|
||||
layerId: 'layer-technical',
|
||||
flowId: 'flow:layer-technical',
|
||||
layerName: '技术文档',
|
||||
layerDescription: '系统架构、数据模型、接口说明、技术方案和技术决策。点击本层可查看全部技术文档并检索。',
|
||||
defaultTags: ['07_技术文档', '技术文档'],
|
||||
category: 'layer-technical',
|
||||
fallbackSummary: '技术文档。',
|
||||
},
|
||||
{
|
||||
dir: `${wiki}/08_测试相关`,
|
||||
relDir: '08_测试相关',
|
||||
layerId: 'layer-testing',
|
||||
flowId: 'flow:layer-testing',
|
||||
layerName: '测试相关',
|
||||
layerDescription: '测试计划、测试用例、缺陷记录、验收记录、上线检查和测试资产。点击本层可查看全部测试相关文档并检索。',
|
||||
defaultTags: ['08_测试相关', '测试相关'],
|
||||
category: 'layer-testing',
|
||||
fallbackSummary: '测试相关文档。',
|
||||
},
|
||||
];
|
||||
|
||||
function listFiles(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir)
|
||||
.filter((name) => /\.(md|html?|xlsx)$/i.test(name))
|
||||
.sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'));
|
||||
}
|
||||
|
||||
function readText(filePath) {
|
||||
if (/\.xlsx$/i.test(filePath)) return readXlsxText(filePath);
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
function readXlsxText(filePath) {
|
||||
const script = [
|
||||
'import json, sys',
|
||||
'from openpyxl import load_workbook',
|
||||
'path = sys.argv[1]',
|
||||
'wb = load_workbook(path, data_only=True, read_only=True)',
|
||||
'out = []',
|
||||
'for ws in wb.worksheets:',
|
||||
' out.append(f"# Sheet: {ws.title}")',
|
||||
' for row in ws.iter_rows(values_only=True):',
|
||||
' vals = [str(v).strip() for v in row if v is not None and str(v).strip()]',
|
||||
' if vals:',
|
||||
' out.append(" | ".join(vals))',
|
||||
'print(json.dumps("\\n".join(out), ensure_ascii=False))',
|
||||
].join('\n');
|
||||
const stdout = execFileSync('python', ['-c', script, filePath], { encoding: 'utf8', maxBuffer: 50 * 1024 * 1024 });
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
|
||||
function cleanLineNumbers(text) {
|
||||
const lines = text.split(/\r?\n/);
|
||||
let changed = 0;
|
||||
@@ -56,15 +115,15 @@ function titleFor(fileName, text) {
|
||||
return path.basename(fileName, path.extname(fileName)).replace(/^\d+[-_]/, '').replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function summaryFor(fileName, text) {
|
||||
function summaryFor(fileName, text, fallbackSummary) {
|
||||
const plain = /\.html?$/i.test(fileName)
|
||||
? stripHtml(text)
|
||||
: text.replace(/[`*_>#|\-\[\]()]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return plain ? plain.slice(0, 180) : '需求文档。';
|
||||
return plain ? plain.slice(0, 180) : fallbackSummary;
|
||||
}
|
||||
|
||||
function tagsFor(text) {
|
||||
const tags = ['05_需求文档', '需求文档'];
|
||||
function tagsFor(text, defaultTags) {
|
||||
const tags = [...defaultTags];
|
||||
const match = text.match(/^tags:\s*\[(.*?)\]/m);
|
||||
if (!match) return tags;
|
||||
for (const item of match[1].split(/[,,]/)) {
|
||||
@@ -80,6 +139,36 @@ function complexityFor(text) {
|
||||
return 'simple';
|
||||
}
|
||||
|
||||
function ensureLayer(graph, config) {
|
||||
let layer = graph.layers.find((item) => item.id === config.layerId);
|
||||
if (!layer) {
|
||||
layer = {
|
||||
id: config.layerId,
|
||||
name: config.layerName,
|
||||
description: config.layerDescription,
|
||||
nodeIds: [config.flowId],
|
||||
};
|
||||
graph.layers.push(layer);
|
||||
}
|
||||
layer.name = config.layerName;
|
||||
layer.description = config.layerDescription;
|
||||
layer.nodeIds ??= [];
|
||||
if (!layer.nodeIds.includes(config.flowId)) layer.nodeIds.unshift(config.flowId);
|
||||
return layer;
|
||||
}
|
||||
|
||||
function updateFlowNode(byId, layer, config) {
|
||||
const count = layer.nodeIds.filter((id) => id !== config.flowId).length;
|
||||
const flow = byId.get(config.flowId);
|
||||
if (flow) {
|
||||
flow.summary = config.layerDescription;
|
||||
flow.knowledgeMeta ??= {};
|
||||
flow.knowledgeMeta.content = `# ${config.layerName}\n\n${config.layerDescription}\n\n本层包含 ${count} 个文档。点击右侧 Files 或在本层详情中选择具体文档查看内容。`;
|
||||
flow.knowledgeMeta.category = config.category;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function updateGraph(graphPath) {
|
||||
const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8'));
|
||||
graph.nodes ??= [];
|
||||
@@ -88,77 +177,65 @@ function updateGraph(graphPath) {
|
||||
|
||||
const byId = new Map(graph.nodes.map((node) => [node.id, node]));
|
||||
const edgeKeys = new Set(graph.edges.map((edge) => `${edge.source}|${edge.target}|${edge.type}`));
|
||||
let layer = graph.layers.find((item) => item.id === 'layer-requirements');
|
||||
if (!layer) {
|
||||
layer = {
|
||||
id: 'layer-requirements',
|
||||
name: '需求文档',
|
||||
description: '所有正式需求、业务规则、需求变更和需求索引。',
|
||||
nodeIds: ['flow:layer-requirements'],
|
||||
};
|
||||
graph.layers.push(layer);
|
||||
}
|
||||
layer.nodeIds ??= [];
|
||||
const stats = [];
|
||||
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
for (const fileName of files) {
|
||||
const absolutePath = `${reqDir}/${fileName}`;
|
||||
const relPath = `05_需求文档/${fileName}`;
|
||||
const nodeId = `doc:${relPath.replace(/\.[^.]+$/, '')}`;
|
||||
const text = cleanLineNumbers(readText(absolutePath));
|
||||
const node = {
|
||||
id: nodeId,
|
||||
type: 'document',
|
||||
name: titleFor(fileName, text),
|
||||
filePath: relPath,
|
||||
summary: summaryFor(fileName, text),
|
||||
tags: tagsFor(text),
|
||||
complexity: complexityFor(text),
|
||||
knowledgeMeta: {
|
||||
content: text,
|
||||
wikilinks: [...text.matchAll(/\[\[([^\]]+)\]\]/g)].map((match) => match[1]),
|
||||
category: 'layer-requirements',
|
||||
},
|
||||
};
|
||||
for (const config of directoryConfigs) {
|
||||
const files = listFiles(config.dir);
|
||||
const layer = ensureLayer(graph, config);
|
||||
let added = 0;
|
||||
let updated = 0;
|
||||
|
||||
if (byId.has(nodeId)) {
|
||||
Object.assign(byId.get(nodeId), node);
|
||||
updated += 1;
|
||||
} else {
|
||||
graph.nodes.push(node);
|
||||
byId.set(nodeId, node);
|
||||
added += 1;
|
||||
for (const fileName of files) {
|
||||
const absolutePath = `${config.dir}/${fileName}`;
|
||||
const relPath = `${config.relDir}/${fileName}`;
|
||||
const nodeId = `doc:${relPath.replace(/\.[^.]+$/, '')}`;
|
||||
const text = cleanLineNumbers(readText(absolutePath));
|
||||
const node = {
|
||||
id: nodeId,
|
||||
type: 'document',
|
||||
name: titleFor(fileName, text),
|
||||
filePath: relPath,
|
||||
summary: summaryFor(fileName, text, config.fallbackSummary),
|
||||
tags: tagsFor(text, config.defaultTags),
|
||||
complexity: complexityFor(text),
|
||||
knowledgeMeta: {
|
||||
content: text,
|
||||
wikilinks: [...text.matchAll(/\[\[([^\]]+)\]\]/g)].map((match) => match[1]),
|
||||
category: config.category,
|
||||
},
|
||||
};
|
||||
|
||||
if (byId.has(nodeId)) {
|
||||
Object.assign(byId.get(nodeId), node);
|
||||
updated += 1;
|
||||
} else {
|
||||
graph.nodes.push(node);
|
||||
byId.set(nodeId, node);
|
||||
added += 1;
|
||||
}
|
||||
|
||||
if (!layer.nodeIds.includes(nodeId)) layer.nodeIds.push(nodeId);
|
||||
const edgeKey = `${config.flowId}|${nodeId}|documents`;
|
||||
if (!edgeKeys.has(edgeKey)) {
|
||||
graph.edges.push({
|
||||
source: config.flowId,
|
||||
target: nodeId,
|
||||
type: 'documents',
|
||||
direction: 'forward',
|
||||
description: '本层文档',
|
||||
weight: 0.65,
|
||||
});
|
||||
edgeKeys.add(edgeKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!layer.nodeIds.includes(nodeId)) layer.nodeIds.push(nodeId);
|
||||
const edgeKey = `flow:layer-requirements|${nodeId}|documents`;
|
||||
if (!edgeKeys.has(edgeKey)) {
|
||||
graph.edges.push({
|
||||
source: 'flow:layer-requirements',
|
||||
target: nodeId,
|
||||
type: 'documents',
|
||||
direction: 'forward',
|
||||
description: '本层文档',
|
||||
weight: 0.65,
|
||||
});
|
||||
edgeKeys.add(edgeKey);
|
||||
}
|
||||
}
|
||||
|
||||
const count = layer.nodeIds.filter((id) => id !== 'flow:layer-requirements').length;
|
||||
const flow = byId.get('flow:layer-requirements');
|
||||
if (flow) {
|
||||
flow.summary = '所有正式需求、业务规则、需求变更和需求索引。点击本层可查看全部需求文档并检索。';
|
||||
flow.knowledgeMeta ??= {};
|
||||
flow.knowledgeMeta.content = `# 需求文档\n\n所有正式需求、业务规则、需求变更和需求索引。点击本层可查看全部需求文档并检索。\n\n本层包含 ${count} 个文档。点击右侧 Files 或在本层详情中选择具体文档查看内容。`;
|
||||
flow.knowledgeMeta.category = 'layer-requirements';
|
||||
stats.push({ layer: config.layerName, added, updated, count: updateFlowNode(byId, layer, config) });
|
||||
}
|
||||
|
||||
graph.project ??= {};
|
||||
graph.project.analyzedAt = new Date().toISOString();
|
||||
fs.writeFileSync(graphPath, `${JSON.stringify(graph, null, 2)}\n`, 'utf8');
|
||||
return { graphPath, added, updated, requirements: count, nodes: graph.nodes.length };
|
||||
return { graphPath, stats, nodes: graph.nodes.length };
|
||||
}
|
||||
|
||||
const results = graphPaths.map(updateGraph);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Highlight, themes } from "prism-react-renderer";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { useDashboardStore } from "../store";
|
||||
import { useI18n } from "../contexts/I18nContext";
|
||||
|
||||
@@ -76,6 +77,11 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function isMarkdownSource(source: SourceFile | null, language: string): boolean {
|
||||
if (!source) return false;
|
||||
return language === "markdown" || /\.md$/i.test(source.path);
|
||||
}
|
||||
|
||||
export default function CodeViewer({
|
||||
accessToken,
|
||||
presentation = "sidebar",
|
||||
@@ -165,6 +171,7 @@ export default function CodeViewer({
|
||||
|
||||
const source = state.source;
|
||||
const language = source?.language ?? fallbackLanguage(node.filePath);
|
||||
const shouldRenderMarkdown = isMarkdownSource(source, language);
|
||||
const lineInfo = highlightedRange
|
||||
? `${t.codeViewer.lines} ${highlightedRange.start}-${highlightedRange.end}`
|
||||
: t.codeViewer.fullFile;
|
||||
@@ -245,43 +252,49 @@ export default function CodeViewer({
|
||||
<span>{source.lineCount} {t.codeViewer.linesLabel}</span>
|
||||
<span>{formatBytes(source.sizeBytes)}</span>
|
||||
</div>
|
||||
<Highlight code={source.content} language={language} theme={themes.vsDark}>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
className={`${className} min-w-max p-0 m-0 ${
|
||||
isModal ? "text-xs leading-5" : "text-[11px] leading-5"
|
||||
} font-mono`}
|
||||
style={{ ...style, background: "transparent" }}
|
||||
>
|
||||
{tokens.map((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const isHighlighted =
|
||||
highlightedRange !== null &&
|
||||
lineNumber >= highlightedRange.start &&
|
||||
lineNumber <= highlightedRange.end;
|
||||
const lineProps = getLineProps({ line });
|
||||
return (
|
||||
<div
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={`${lineProps.className} flex ${
|
||||
isHighlighted ? "bg-accent/15" : "hover:bg-elevated/40"
|
||||
}`}
|
||||
>
|
||||
<span className="w-12 shrink-0 select-none border-r border-border-subtle pr-3 text-right text-text-muted bg-surface/60">
|
||||
{lineNumber}
|
||||
</span>
|
||||
<span className="pl-3 pr-6 whitespace-pre">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
{shouldRenderMarkdown ? (
|
||||
<article className={`markdown-reader ${isModal ? "markdown-reader-modal" : ""}`}>
|
||||
<ReactMarkdown>{source.content}</ReactMarkdown>
|
||||
</article>
|
||||
) : (
|
||||
<Highlight code={source.content} language={language} theme={themes.vsDark}>
|
||||
{({ className, style, tokens, getLineProps, getTokenProps }) => (
|
||||
<pre
|
||||
className={`${className} min-w-max p-0 m-0 ${
|
||||
isModal ? "text-xs leading-5" : "text-[11px] leading-5"
|
||||
} font-mono`}
|
||||
style={{ ...style, background: "transparent" }}
|
||||
>
|
||||
{tokens.map((line, index) => {
|
||||
const lineNumber = index + 1;
|
||||
const isHighlighted =
|
||||
highlightedRange !== null &&
|
||||
lineNumber >= highlightedRange.start &&
|
||||
lineNumber <= highlightedRange.end;
|
||||
const lineProps = getLineProps({ line });
|
||||
return (
|
||||
<div
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={`${lineProps.className} flex ${
|
||||
isHighlighted ? "bg-accent/15" : "hover:bg-elevated/40"
|
||||
}`}
|
||||
>
|
||||
<span className="w-12 shrink-0 select-none border-r border-border-subtle pr-3 text-right text-text-muted bg-surface/60">
|
||||
{lineNumber}
|
||||
</span>
|
||||
<span className="pl-3 pr-6 whitespace-pre">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
)}
|
||||
</Highlight>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -218,6 +218,190 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Markdown document reader */
|
||||
.markdown-reader {
|
||||
width: min(100%, 980px);
|
||||
margin: 0 auto;
|
||||
padding: 32px 40px 56px;
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
line-height: 1.85;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.markdown-reader-modal {
|
||||
width: min(100%, 1040px);
|
||||
padding: 40px 56px 72px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.markdown-reader h1,
|
||||
.markdown-reader h2,
|
||||
.markdown-reader h3,
|
||||
.markdown-reader h4,
|
||||
.markdown-reader h5,
|
||||
.markdown-reader h6 {
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 750;
|
||||
line-height: 1.32;
|
||||
letter-spacing: -0.01em;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
|
||||
.markdown-reader h1 {
|
||||
margin: 0 0 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--color-border-medium);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.markdown-reader h2 {
|
||||
margin: 42px 0 18px;
|
||||
padding-left: 14px;
|
||||
border-left: 3px solid var(--color-accent);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.markdown-reader h3 {
|
||||
margin: 32px 0 14px;
|
||||
color: var(--color-accent-bright);
|
||||
font-size: 1.18rem;
|
||||
}
|
||||
|
||||
.markdown-reader h4,
|
||||
.markdown-reader h5,
|
||||
.markdown-reader h6 {
|
||||
margin: 24px 0 10px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.markdown-reader p,
|
||||
.markdown-reader ul,
|
||||
.markdown-reader ol,
|
||||
.markdown-reader blockquote,
|
||||
.markdown-reader table,
|
||||
.markdown-reader pre {
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.markdown-reader p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.markdown-reader strong {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-reader a {
|
||||
color: var(--color-node-document);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-node-document) 45%, transparent);
|
||||
}
|
||||
|
||||
.markdown-reader ul,
|
||||
.markdown-reader ol {
|
||||
padding-left: 1.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.markdown-reader li {
|
||||
margin: 7px 0;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.markdown-reader li::marker {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.markdown-reader blockquote {
|
||||
border-left: 3px solid var(--color-border-medium);
|
||||
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||
padding: 14px 18px;
|
||||
border-radius: 0 10px 10px 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.markdown-reader code {
|
||||
color: var(--color-accent-bright);
|
||||
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 5px;
|
||||
padding: 0.12em 0.36em;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-reader pre {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--color-root) 82%, var(--color-elevated));
|
||||
padding: 18px 20px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.markdown-reader pre code {
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.88em;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.markdown-reader hr {
|
||||
margin: 34px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.markdown-reader table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.markdown-reader th,
|
||||
.markdown-reader td {
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
padding: 10px 12px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.markdown-reader th {
|
||||
background: color-mix(in srgb, var(--color-accent) 11%, transparent);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-reader td {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.markdown-reader,
|
||||
.markdown-reader-modal {
|
||||
padding: 24px 20px 44px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.markdown-reader h1 {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.markdown-reader h2 {
|
||||
font-size: 1.28rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep scroll functionality */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
|
||||
Reference in New Issue
Block a user