@jjlmoya/utils-games-development 1.52.0 → 1.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +6 -2
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/localizationSanitizer/app-client.ts +270 -0
- package/src/tool/localizationSanitizer/bibliography.astro +6 -0
- package/src/tool/localizationSanitizer/bibliography.ts +14 -0
- package/src/tool/localizationSanitizer/component.astro +116 -0
- package/src/tool/localizationSanitizer/csv-parser.ts +95 -0
- package/src/tool/localizationSanitizer/csv-sanitizer.ts +98 -0
- package/src/tool/localizationSanitizer/entry.ts +28 -0
- package/src/tool/localizationSanitizer/i18n/de.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/en.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/es.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/fr.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/id.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/it.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/ja.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/ko.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/nl.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/pl.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/pt.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/ru.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/sv.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/tr.ts +176 -0
- package/src/tool/localizationSanitizer/i18n/zh.ts +176 -0
- package/src/tool/localizationSanitizer/index.ts +12 -0
- package/src/tool/localizationSanitizer/json-sanitizer.ts +118 -0
- package/src/tool/localizationSanitizer/localization-sanitizer.css +634 -0
- package/src/tool/localizationSanitizer/logic.test.ts +38 -0
- package/src/tool/localizationSanitizer/logic.ts +23 -0
- package/src/tool/localizationSanitizer/seo.astro +15 -0
- package/src/tool/localizationSanitizer/types.ts +55 -0
- package/src/tool/localizationSanitizer/ui.ts +39 -0
- package/src/tool/steamBbcodeTranslator/app-client.ts +125 -0
- package/src/tool/steamBbcodeTranslator/bbcode-parser.ts +110 -0
- package/src/tool/steamBbcodeTranslator/bibliography.astro +6 -0
- package/src/tool/steamBbcodeTranslator/bibliography.ts +14 -0
- package/src/tool/steamBbcodeTranslator/component.astro +83 -0
- package/src/tool/steamBbcodeTranslator/entry.ts +28 -0
- package/src/tool/steamBbcodeTranslator/html-parser.ts +42 -0
- package/src/tool/steamBbcodeTranslator/html-renderer.ts +71 -0
- package/src/tool/steamBbcodeTranslator/i18n/de.ts +207 -0
- package/src/tool/steamBbcodeTranslator/i18n/en.ts +236 -0
- package/src/tool/steamBbcodeTranslator/i18n/es.ts +207 -0
- package/src/tool/steamBbcodeTranslator/i18n/fr.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/id.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/it.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/ja.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/ko.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/nl.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/pl.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/pt.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/ru.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/sv.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/tr.ts +195 -0
- package/src/tool/steamBbcodeTranslator/i18n/zh.ts +195 -0
- package/src/tool/steamBbcodeTranslator/index.ts +12 -0
- package/src/tool/steamBbcodeTranslator/logic.test.ts +41 -0
- package/src/tool/steamBbcodeTranslator/logic.ts +96 -0
- package/src/tool/steamBbcodeTranslator/markdown-parser.ts +115 -0
- package/src/tool/steamBbcodeTranslator/markdown-renderer.ts +85 -0
- package/src/tool/steamBbcodeTranslator/seo.astro +15 -0
- package/src/tool/steamBbcodeTranslator/steam-bbcode-translator.css +392 -0
- package/src/tool/steamBbcodeTranslator/storage.ts +30 -0
- package/src/tool/steamBbcodeTranslator/types.ts +36 -0
- package/src/tool/steamBbcodeTranslator/ui.ts +18 -0
- package/src/tools.ts +4 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export type TranslationFormat = 'csv' | 'json';
|
|
2
|
+
|
|
3
|
+
export type SanitizerIssueKind = 'empty' | 'duplicate' | 'malformed' | 'parse';
|
|
4
|
+
|
|
5
|
+
export interface SanitizerIssue {
|
|
6
|
+
kind: SanitizerIssueKind;
|
|
7
|
+
row: number;
|
|
8
|
+
column: string;
|
|
9
|
+
key: string;
|
|
10
|
+
detail: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SanitizerStats {
|
|
14
|
+
totalRows: number;
|
|
15
|
+
cleanRows: number;
|
|
16
|
+
emptyCells: number;
|
|
17
|
+
duplicateKeys: number;
|
|
18
|
+
malformedRows: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SanitizerResult {
|
|
22
|
+
format: TranslationFormat;
|
|
23
|
+
valid: boolean;
|
|
24
|
+
headers: string[];
|
|
25
|
+
rows: string[][];
|
|
26
|
+
cleanText: string;
|
|
27
|
+
issues: SanitizerIssue[];
|
|
28
|
+
stats: SanitizerStats;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ParsedCsvRow {
|
|
32
|
+
values: string[];
|
|
33
|
+
malformed: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface JsonRowRecord {
|
|
37
|
+
source: Record<string, unknown>;
|
|
38
|
+
row: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface IssueInput {
|
|
42
|
+
kind: SanitizerIssueKind;
|
|
43
|
+
row: number;
|
|
44
|
+
column: string;
|
|
45
|
+
key: string;
|
|
46
|
+
detail: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const EMPTY_STATS: SanitizerStats = {
|
|
50
|
+
totalRows: 0,
|
|
51
|
+
cleanRows: 0,
|
|
52
|
+
emptyCells: 0,
|
|
53
|
+
duplicateKeys: 0,
|
|
54
|
+
malformedRows: 0,
|
|
55
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface LocalizationSanitizerUI extends Record<string, string> {
|
|
2
|
+
csvTab: string;
|
|
3
|
+
jsonTab: string;
|
|
4
|
+
dropTitle: string;
|
|
5
|
+
dropSubtitle: string;
|
|
6
|
+
browseButton: string;
|
|
7
|
+
sampleButton: string;
|
|
8
|
+
clearButton: string;
|
|
9
|
+
inputLabel: string;
|
|
10
|
+
formatLabel: string;
|
|
11
|
+
healthLabel: string;
|
|
12
|
+
readyStatus: string;
|
|
13
|
+
reviewStatus: string;
|
|
14
|
+
emptyCellsLabel: string;
|
|
15
|
+
duplicateKeysLabel: string;
|
|
16
|
+
malformedRowsLabel: string;
|
|
17
|
+
cleanRowsLabel: string;
|
|
18
|
+
issueListTitle: string;
|
|
19
|
+
noIssues: string;
|
|
20
|
+
previewTitle: string;
|
|
21
|
+
previewSubtitle: string;
|
|
22
|
+
exportTitle: string;
|
|
23
|
+
exportSubtitle: string;
|
|
24
|
+
downloadButton: string;
|
|
25
|
+
copyButton: string;
|
|
26
|
+
copiedMessage: string;
|
|
27
|
+
emptyIssue: string;
|
|
28
|
+
duplicateIssue: string;
|
|
29
|
+
malformedIssue: string;
|
|
30
|
+
parseIssue: string;
|
|
31
|
+
rowLabel: string;
|
|
32
|
+
columnLabel: string;
|
|
33
|
+
keyLabel: string;
|
|
34
|
+
sampleFileName: string;
|
|
35
|
+
privacyNote: string;
|
|
36
|
+
waitingTitle: string;
|
|
37
|
+
waitingSubtitle: string;
|
|
38
|
+
fileTypeNote: string;
|
|
39
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { AutoTranslationResult, MarkupSyntax, TranslationResult } from './types';
|
|
2
|
+
import { clearSourceFromStorage, loadSourceFromStorage, saveSourceToStorage } from './storage';
|
|
3
|
+
import { detectSyntax, getAutoPreviewHtml, translateToAll } from './logic';
|
|
4
|
+
|
|
5
|
+
interface AppElements {
|
|
6
|
+
root: HTMLElement;
|
|
7
|
+
source: HTMLTextAreaElement;
|
|
8
|
+
detected: HTMLElement;
|
|
9
|
+
outputGrid: HTMLElement;
|
|
10
|
+
preview: HTMLElement;
|
|
11
|
+
characterCount: HTMLElement;
|
|
12
|
+
blockCount: HTMLElement;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getElements(root: HTMLElement): AppElements | null {
|
|
16
|
+
const source = root.querySelector('#bbcode-source') as HTMLTextAreaElement | null;
|
|
17
|
+
const detected = root.querySelector('#bbcode-detected') as HTMLElement | null;
|
|
18
|
+
const outputGrid = root.querySelector('#bbcode-output-grid') as HTMLElement | null;
|
|
19
|
+
const preview = root.querySelector('#bbcode-preview') as HTMLElement | null;
|
|
20
|
+
const characterCount = root.querySelector('#bbcode-character-count') as HTMLElement | null;
|
|
21
|
+
const blockCount = root.querySelector('#bbcode-block-count') as HTMLElement | null;
|
|
22
|
+
|
|
23
|
+
if (!source || !detected || !outputGrid || !preview || !characterCount || !blockCount) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { root, source, detected, outputGrid, preview, characterCount, blockCount };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function copyText(value: string, button: HTMLButtonElement, copiedText: string): Promise<void> {
|
|
31
|
+
await navigator.clipboard?.writeText(value);
|
|
32
|
+
const original = button.textContent;
|
|
33
|
+
button.textContent = copiedText;
|
|
34
|
+
window.setTimeout(() => {
|
|
35
|
+
button.textContent = original;
|
|
36
|
+
}, 1200);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createCard(target: MarkupSyntax, translation: TranslationResult, names: Record<MarkupSyntax, string>, dataset: DOMStringMap): HTMLElement {
|
|
40
|
+
const card = document.createElement('article');
|
|
41
|
+
card.className = 'bbcode-output-card';
|
|
42
|
+
|
|
43
|
+
const heading = document.createElement('div');
|
|
44
|
+
heading.className = 'bbcode-output-card-head';
|
|
45
|
+
|
|
46
|
+
const label = document.createElement('h3');
|
|
47
|
+
label.textContent = names[target];
|
|
48
|
+
|
|
49
|
+
const actions = document.createElement('div');
|
|
50
|
+
actions.className = 'bbcode-output-actions';
|
|
51
|
+
|
|
52
|
+
const copyButton = document.createElement('button');
|
|
53
|
+
copyButton.type = 'button';
|
|
54
|
+
copyButton.className = 'bbcode-icon-button';
|
|
55
|
+
copyButton.textContent = dataset.copy ?? 'Copy';
|
|
56
|
+
copyButton.addEventListener('click', () => copyText(translation.output, copyButton, dataset.copied ?? 'Copied'));
|
|
57
|
+
|
|
58
|
+
actions.append(copyButton);
|
|
59
|
+
heading.append(label, actions);
|
|
60
|
+
|
|
61
|
+
const pre = document.createElement('pre');
|
|
62
|
+
pre.textContent = translation.output;
|
|
63
|
+
card.append(heading, pre);
|
|
64
|
+
|
|
65
|
+
return card;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function updateMeta(elements: AppElements, names: Record<MarkupSyntax, string>, result: AutoTranslationResult): void {
|
|
69
|
+
const { root, source, detected, characterCount, blockCount } = elements;
|
|
70
|
+
const syntax = detectSyntax(source.value);
|
|
71
|
+
detected.textContent = source.value.trim()
|
|
72
|
+
? `${root.dataset.detectedLabel ?? 'Detected'}: ${names[syntax]}`
|
|
73
|
+
: root.dataset.detectedEmpty ?? '';
|
|
74
|
+
|
|
75
|
+
characterCount.textContent = String(source.value.length);
|
|
76
|
+
const firstOutputKey = Object.keys(result.outputs)[0] as MarkupSyntax | undefined;
|
|
77
|
+
const firstBlockCount = firstOutputKey ? result.outputs[firstOutputKey]?.stats.blocks ?? 0 : 0;
|
|
78
|
+
blockCount.textContent = String(firstBlockCount);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderOutputCards(elements: AppElements, names: Record<MarkupSyntax, string>, result: AutoTranslationResult): void {
|
|
82
|
+
elements.outputGrid.innerHTML = '';
|
|
83
|
+
Object.entries(result.outputs).forEach(([target, translation]) => {
|
|
84
|
+
if (!translation) return;
|
|
85
|
+
const card = createCard(target as MarkupSyntax, translation, names, elements.root.dataset);
|
|
86
|
+
elements.outputGrid.append(card);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderApp(elements: AppElements, names: Record<MarkupSyntax, string>): void {
|
|
91
|
+
const result = translateToAll(elements.source.value);
|
|
92
|
+
updateMeta(elements, names, result);
|
|
93
|
+
renderOutputCards(elements, names, result);
|
|
94
|
+
elements.preview.innerHTML = getAutoPreviewHtml(elements.source.value) || `<p>${elements.root.dataset.previewEmpty ?? ''}</p>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function initSteamBbcodeTranslatorApp(): void {
|
|
98
|
+
const root = document.getElementById('steam-bbcode-translator');
|
|
99
|
+
if (!root) return;
|
|
100
|
+
const elements = getElements(root);
|
|
101
|
+
if (!elements) return;
|
|
102
|
+
|
|
103
|
+
const names: Record<MarkupSyntax, string> = {
|
|
104
|
+
bbcode: root.dataset.bbcode ?? 'Steam BBCode',
|
|
105
|
+
markdown: root.dataset.markdown ?? 'Markdown',
|
|
106
|
+
html: root.dataset.html ?? 'HTML'
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const stored = loadSourceFromStorage();
|
|
110
|
+
if (stored) elements.source.value = stored;
|
|
111
|
+
|
|
112
|
+
elements.source.addEventListener('input', () => {
|
|
113
|
+
saveSourceToStorage(elements.source.value);
|
|
114
|
+
renderApp(elements, names);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
root.querySelector('#bbcode-clear')?.addEventListener('click', () => {
|
|
118
|
+
elements.source.value = '';
|
|
119
|
+
clearSourceFromStorage();
|
|
120
|
+
renderApp(elements, names);
|
|
121
|
+
elements.source.focus();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
renderApp(elements, names);
|
|
125
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Node, TagNode, TextNode } from './types';
|
|
2
|
+
|
|
3
|
+
const BB_TAG_PATTERN = /\[(\/)?([a-z][a-z0-9]*|\*)(?:=([^\]]*))?\]/gi;
|
|
4
|
+
const SUPPORTED_TAGS = new Set(['h1', 'h2', 'h3', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'url', 'code', 'quote', 'spoiler', 'list', 'olist', '*']);
|
|
5
|
+
|
|
6
|
+
export function text(value: string): TextNode {
|
|
7
|
+
return { kind: 'text', value };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function tag(name: string, value: string | undefined, children: Node[] = []): TagNode {
|
|
11
|
+
return { kind: 'tag', name, value, children };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ParseStackEntry {
|
|
15
|
+
node: TagNode;
|
|
16
|
+
parent: Node[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ParseContext {
|
|
20
|
+
stack: ParseStackEntry[];
|
|
21
|
+
root: Node[];
|
|
22
|
+
warnings: string[];
|
|
23
|
+
append: (n: Node) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function handleAsteriskTag(ctx: ParseContext, full: string): void {
|
|
27
|
+
const inList = ctx.stack.some((entry) => entry.node.name === 'list' || entry.node.name === 'olist');
|
|
28
|
+
if (!inList) {
|
|
29
|
+
ctx.warnings.push('A list item marker was found outside a list.');
|
|
30
|
+
ctx.append(text(full));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
while (ctx.stack.length) {
|
|
34
|
+
const top = ctx.stack[ctx.stack.length - 1];
|
|
35
|
+
if (top && top.node.name === '*') ctx.stack.pop();
|
|
36
|
+
else break;
|
|
37
|
+
}
|
|
38
|
+
const listParent = ctx.stack[ctx.stack.length - 1];
|
|
39
|
+
if (listParent && (listParent.node.name === 'list' || listParent.node.name === 'olist')) {
|
|
40
|
+
const item = tag('*', undefined);
|
|
41
|
+
listParent.node.children.push(item);
|
|
42
|
+
ctx.stack.push({ node: item, parent: listParent.node.children });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handleClosingTag(ctx: ParseContext, name: string, full: string): void {
|
|
47
|
+
const matchingIndex = [...ctx.stack].reverse().findIndex((entry) => entry.node.name === name);
|
|
48
|
+
if (matchingIndex === -1) {
|
|
49
|
+
ctx.warnings.push(`Closing tag [/${name}] has no matching opening tag.`);
|
|
50
|
+
ctx.append(text(full));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
for (let i = 0; i <= matchingIndex; i += 1) ctx.stack.pop();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function handleOpeningTag(ctx: ParseContext, name: string, value?: string): void {
|
|
57
|
+
const node = tag(name, value);
|
|
58
|
+
ctx.append(node);
|
|
59
|
+
if (name !== 'br') {
|
|
60
|
+
const top = ctx.stack[ctx.stack.length - 1];
|
|
61
|
+
ctx.stack.push({ node, parent: top ? top.node.children : ctx.root });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function processMatch(ctx: ParseContext, match: RegExpExecArray, full: string, rawName: string): void {
|
|
66
|
+
const closing = Boolean(match[1]);
|
|
67
|
+
const name = rawName.toLowerCase();
|
|
68
|
+
const value = match[3]?.trim();
|
|
69
|
+
|
|
70
|
+
if (!SUPPORTED_TAGS.has(name)) {
|
|
71
|
+
ctx.warnings.push(`Unsupported tag [${name}] was kept as text.`);
|
|
72
|
+
ctx.append(text(full));
|
|
73
|
+
} else if (name === '*') {
|
|
74
|
+
handleAsteriskTag(ctx, full);
|
|
75
|
+
} else if (closing) {
|
|
76
|
+
handleClosingTag(ctx, name, full);
|
|
77
|
+
} else {
|
|
78
|
+
handleOpeningTag(ctx, name, value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseBbcode(source: string): { nodes: Node[]; warnings: string[]; tagCount: number } {
|
|
83
|
+
const root: Node[] = [];
|
|
84
|
+
const stack: ParseStackEntry[] = [];
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
let tagCount = 0;
|
|
87
|
+
const warnings: string[] = [];
|
|
88
|
+
|
|
89
|
+
const append = (nodeToAppend: Node) => {
|
|
90
|
+
const top = stack[stack.length - 1];
|
|
91
|
+
const target = top ? top.node.children : root;
|
|
92
|
+
target.push(nodeToAppend);
|
|
93
|
+
};
|
|
94
|
+
const ctx: ParseContext = { stack, root, warnings, append };
|
|
95
|
+
|
|
96
|
+
for (const match of source.matchAll(BB_TAG_PATTERN)) {
|
|
97
|
+
const full = match[0];
|
|
98
|
+
const index = match.index ?? 0;
|
|
99
|
+
if (index > cursor) append(text(source.slice(cursor, index)));
|
|
100
|
+
cursor = index + full.length;
|
|
101
|
+
if (!match[2]) continue;
|
|
102
|
+
tagCount += 1;
|
|
103
|
+
processMatch(ctx, match, full, match[2]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (cursor < source.length) append(text(source.slice(cursor)));
|
|
107
|
+
const top = stack[stack.length - 1];
|
|
108
|
+
if (top) warnings.push(`Unclosed tag [${top.node.name}] was closed automatically.`);
|
|
109
|
+
return { nodes: root, warnings: [...new Set(warnings)], tagCount };
|
|
110
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const bibliographyEntries: { name: string; url: string }[] = [
|
|
2
|
+
{
|
|
3
|
+
name: 'Steamworks Store Page Written Description',
|
|
4
|
+
url: 'https://partner.steamgames.com/doc/store/page/description?language=english'
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
name: 'Steamworks Importing HTML',
|
|
8
|
+
url: 'https://partner.steamgames.com/doc/marketing/event_tools/import?language=english'
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: 'itch.io Creator Documentation',
|
|
12
|
+
url: 'https://itch.io/docs/creators/getting-started'
|
|
13
|
+
}
|
|
14
|
+
];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { SteamBbcodeTranslatorUI } from './ui';
|
|
3
|
+
import { sampleBbcode } from './logic';
|
|
4
|
+
import './steam-bbcode-translator.css';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
ui: SteamBbcodeTranslatorUI;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { ui: t } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<div
|
|
14
|
+
class="bbcode-root"
|
|
15
|
+
id="steam-bbcode-translator"
|
|
16
|
+
data-detected-label={t.detectedLabel}
|
|
17
|
+
data-detected-empty={t.detectedEmpty}
|
|
18
|
+
data-bbcode={t.bbcode}
|
|
19
|
+
data-markdown={t.markdown}
|
|
20
|
+
data-html={t.html}
|
|
21
|
+
data-copy={t.copy}
|
|
22
|
+
data-copied={t.copied}
|
|
23
|
+
data-preview-empty={t.previewEmpty}
|
|
24
|
+
data-characters={t.characters}
|
|
25
|
+
data-blocks={t.blocks}
|
|
26
|
+
>
|
|
27
|
+
<div class="bbcode-header">
|
|
28
|
+
<div class="bbcode-title-row">
|
|
29
|
+
<span class="bbcode-mark" aria-hidden="true">[ / ]</span>
|
|
30
|
+
<div>
|
|
31
|
+
<span class="bbcode-kicker">STEAM MARKUP TRANSLATOR</span>
|
|
32
|
+
<strong>{t.privacyNote}</strong>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
<span class="bbcode-draft-note">{t.persistenceNote}</span>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<section class="bbcode-input-card">
|
|
39
|
+
<div class="bbcode-section-head">
|
|
40
|
+
<div>
|
|
41
|
+
<span class="bbcode-step">01</span>
|
|
42
|
+
<h2>{t.editorLabel}</h2>
|
|
43
|
+
</div>
|
|
44
|
+
<span class="bbcode-detected" id="bbcode-detected">{t.detectedEmpty}</span>
|
|
45
|
+
</div>
|
|
46
|
+
<label class="bbcode-sr-only" for="bbcode-source">{t.editorLabel}</label>
|
|
47
|
+
<textarea id="bbcode-source" spellcheck="false" aria-describedby="bbcode-editor-hint">{sampleBbcode}</textarea>
|
|
48
|
+
<div class="bbcode-input-footer">
|
|
49
|
+
<p id="bbcode-editor-hint">{t.editorHint}</p>
|
|
50
|
+
<div class="bbcode-input-meta">
|
|
51
|
+
<span><strong id="bbcode-character-count">0</strong> {t.characters}</span>
|
|
52
|
+
<span><strong id="bbcode-block-count">0</strong> {t.blocks}</span>
|
|
53
|
+
<button type="button" class="bbcode-clear-button" id="bbcode-clear">{t.clear}</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<section class="bbcode-output-section" aria-live="polite">
|
|
59
|
+
<div class="bbcode-output-heading">
|
|
60
|
+
<span class="bbcode-step">02</span>
|
|
61
|
+
<h2>{t.detectedLabel}</h2>
|
|
62
|
+
</div>
|
|
63
|
+
<div class="bbcode-output-grid" id="bbcode-output-grid"></div>
|
|
64
|
+
</section>
|
|
65
|
+
|
|
66
|
+
<section class="bbcode-preview-card">
|
|
67
|
+
<div class="bbcode-output-heading">
|
|
68
|
+
<span class="bbcode-step">03</span>
|
|
69
|
+
<h2>{t.previewLabel}</h2>
|
|
70
|
+
</div>
|
|
71
|
+
<div id="bbcode-preview" class="bbcode-preview-surface"><p>{t.previewEmpty}</p></div>
|
|
72
|
+
</section>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<script>
|
|
76
|
+
import { initSteamBbcodeTranslatorApp } from './app-client';
|
|
77
|
+
|
|
78
|
+
if (document.readyState === 'loading') {
|
|
79
|
+
document.addEventListener('DOMContentLoaded', initSteamBbcodeTranslatorApp);
|
|
80
|
+
} else {
|
|
81
|
+
initSteamBbcodeTranslatorApp();
|
|
82
|
+
}
|
|
83
|
+
</script>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GamesToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
import type { SteamBbcodeTranslatorUI } from './ui';
|
|
3
|
+
|
|
4
|
+
export type { SteamBbcodeTranslatorUI };
|
|
5
|
+
|
|
6
|
+
export type SteamBbcodeTranslatorLocaleContent = ToolLocaleContent<SteamBbcodeTranslatorUI>;
|
|
7
|
+
|
|
8
|
+
export const steamBbcodeTranslator: GamesToolEntry<SteamBbcodeTranslatorUI> = {
|
|
9
|
+
id: 'steam-bbcode-translator',
|
|
10
|
+
icons: { bg: 'mdi:gamepad-variant', fg: 'mdi:code-brackets' },
|
|
11
|
+
i18n: {
|
|
12
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
13
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
14
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
15
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
16
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
17
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
18
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
19
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
20
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
21
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
22
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
23
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
24
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
25
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
26
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TranslationResult } from './types';
|
|
2
|
+
|
|
3
|
+
export function decodeHtml(value: string): string {
|
|
4
|
+
return value
|
|
5
|
+
.replace(/</g, '<')
|
|
6
|
+
.replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"')
|
|
8
|
+
.replace(/'/g, "'")
|
|
9
|
+
.replace(/&/g, '&');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function countBlocks(source: string): number {
|
|
13
|
+
return source.trim() ? source.split(/\n{2,}/).filter(Boolean).length : 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function htmlToBbcode(source: string): TranslationResult {
|
|
17
|
+
let output = source.replace(/<!--[\s\S]*?-->/g, '');
|
|
18
|
+
const warnings: string[] = [];
|
|
19
|
+
output = output.replace(/<h([1-3])\b[^>]*>/gi, (_, level: string) => `[h${level}]`);
|
|
20
|
+
output = output.replace(/<\/h([1-3])>/gi, (_, level: string) => `[/h${level}]`);
|
|
21
|
+
const replacements: [RegExp, string][] = [
|
|
22
|
+
[/<(?:strong|b)\b[^>]*>/gi, '[b]'], [/<\/(?:strong|b)>/gi, '[/b]'],
|
|
23
|
+
[/<(?:em|i)\b[^>]*>/gi, '[i]'], [/<\/(?:em|i)>/gi, '[/i]'],
|
|
24
|
+
[/<u\b[^>]*>/gi, '[u]'], [/<\/u>/gi, '[/u]'],
|
|
25
|
+
[/<(?:del|s|strike)\b[^>]*>/gi, '[s]'], [/<\/(?:del|s|strike)>/gi, '[/s]'],
|
|
26
|
+
[/<blockquote\b[^>]*>/gi, '[quote]'], [/<\/blockquote>/gi, '[/quote]'],
|
|
27
|
+
[/<code\b[^>]*>/gi, '[code]'], [/<\/code>/gi, '[/code]'],
|
|
28
|
+
[/<pre\b[^>]*>/gi, ''], [/<\/pre>/gi, ''],
|
|
29
|
+
[/<details\b[^>]*>/gi, '[spoiler]'], [/<\/details>/gi, '[/spoiler]'],
|
|
30
|
+
[/<summary\b[^>]*>.*?<\/summary>/gi, ''],
|
|
31
|
+
[/<ul\b[^>]*>/gi, '[list]'], [/<\/ul>/gi, '[/list]'],
|
|
32
|
+
[/<ol\b[^>]*>/gi, '[olist]'], [/<\/ol>/gi, '[/olist]'],
|
|
33
|
+
[/<li\b[^>]*>/gi, '[*]'], [/<\/li>/gi, '\n'],
|
|
34
|
+
[/<br\s*\/?>/gi, '\n'], [/<p\b[^>]*>/gi, ''], [/<\/p>/gi, '\n\n']
|
|
35
|
+
];
|
|
36
|
+
for (const [pattern, replacement] of replacements) output = output.replace(pattern, replacement);
|
|
37
|
+
output = output.replace(/<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[url=$1]$2[/url]');
|
|
38
|
+
if (/<[a-z][^>]*>/i.test(output)) warnings.push('Unsupported HTML tags were removed from the BBCode result.');
|
|
39
|
+
output = decodeHtml(output.replace(/<[^>]+>/g, '')).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
40
|
+
const tagsCount = (source.match(/<\/?[a-z][^>]*>/gi) ?? []).length;
|
|
41
|
+
return { output, warnings, stats: { characters: output.length, tags: tagsCount, blocks: countBlocks(output) } };
|
|
42
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Node, TagNode } from './types';
|
|
2
|
+
|
|
3
|
+
export function escapeHtml(value: string): string {
|
|
4
|
+
return value.replace(/[&<>"']/g, (character) => {
|
|
5
|
+
if (character === '&') return '&';
|
|
6
|
+
if (character === '<') return '<';
|
|
7
|
+
if (character === '>') return '>';
|
|
8
|
+
if (character === '"') return '"';
|
|
9
|
+
return ''';
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function inlineHtml(nodes: Node[]): string {
|
|
14
|
+
return nodes.map((node) => node.kind === 'text' ? escapeHtml(node.value) : renderHtmlNode(node, true)).join('');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function renderHtmlList(node: TagNode, ordered: boolean): string {
|
|
18
|
+
const items = node.children.filter((child): child is TagNode => child.kind === 'tag' && child.name === '*');
|
|
19
|
+
const tagName = ordered ? 'ol' : 'ul';
|
|
20
|
+
const listItems = items.map((item) => {
|
|
21
|
+
const inner = item.children.map((child) => {
|
|
22
|
+
if (child.kind === 'tag' && (child.name === 'list' || child.name === 'olist')) return renderHtmlNode(child);
|
|
23
|
+
if (child.kind === 'text') return escapeHtml(child.value);
|
|
24
|
+
return renderHtmlNode(child, true);
|
|
25
|
+
}).join('');
|
|
26
|
+
return `<li>${inner}</li>`;
|
|
27
|
+
}).join('');
|
|
28
|
+
return `<${tagName}>${listItems}</${tagName}>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderHtmlHeading(name: string, content: string): string | null {
|
|
32
|
+
if (name === 'h1') return `<h1>${content.trim()}</h1>`;
|
|
33
|
+
if (name === 'h2') return `<h2>${content.trim()}</h2>`;
|
|
34
|
+
if (name === 'h3') return `<h3>${content.trim()}</h3>`;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderHtmlInlineStyle(name: string, content: string): string | null {
|
|
39
|
+
if (name === 'b' || name === 'strong') return `<strong>${content}</strong>`;
|
|
40
|
+
if (name === 'i' || name === 'em') return `<em>${content}</em>`;
|
|
41
|
+
if (name === 'u') return `<u>${content}</u>`;
|
|
42
|
+
if (name === 's' || name === 'strike') return `<del>${content}</del>`;
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function renderHtmlUrlOrCode(name: string, content: string, value?: string, inline = false): string {
|
|
47
|
+
if (name === 'url') return value ? `<a href="${escapeHtml(value)}">${content || escapeHtml(value)}</a>` : content;
|
|
48
|
+
if (name === 'code') return inline ? `<code>${content}</code>` : `<pre><code>${content}</code></pre>`;
|
|
49
|
+
return content;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderHtmlFormatNode(name: string, content: string, value?: string, inline = false): string {
|
|
53
|
+
const heading = renderHtmlHeading(name, content);
|
|
54
|
+
if (heading !== null) return heading;
|
|
55
|
+
const inlineStyle = renderHtmlInlineStyle(name, content);
|
|
56
|
+
if (inlineStyle !== null) return inlineStyle;
|
|
57
|
+
return renderHtmlUrlOrCode(name, content, value, inline);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function renderHtmlNode(node: TagNode, inline = false): string {
|
|
61
|
+
const content = inlineHtml(node.children);
|
|
62
|
+
if (node.name === 'quote') return `<blockquote>${content}</blockquote>`;
|
|
63
|
+
if (node.name === 'spoiler') return `<details><summary>Spoiler</summary>${content}</details>`;
|
|
64
|
+
if (node.name === 'list') return renderHtmlList(node, false);
|
|
65
|
+
if (node.name === 'olist') return renderHtmlList(node, true);
|
|
66
|
+
return renderHtmlFormatNode(node.name, content, node.value, inline);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderHtml(nodes: Node[]): string {
|
|
70
|
+
return nodes.map((node) => node.kind === 'text' ? escapeHtml(node.value) : renderHtmlNode(node)).join('').replace(/\n{2,}/g, '<br>');
|
|
71
|
+
}
|