Use marked module for some Markdown processing
This commit is contained in:
+31
-76
@@ -1,3 +1,11 @@
|
||||
import { stripMarkdownFrontmatter } from './markdown.js';
|
||||
import { marked, Renderer } from '../vendor/marked.esm.js';
|
||||
|
||||
const summaryMarkdownRenderer = new Renderer();
|
||||
summaryMarkdownRenderer.html = token => {
|
||||
return escapeHtml(typeof token === 'string' ? token : (token.raw || token.text || ''));
|
||||
};
|
||||
|
||||
export function clearIssues() {
|
||||
document.getElementById('addressed-issues').innerHTML = '';
|
||||
document.getElementById('known-issues').innerHTML = '';
|
||||
@@ -134,7 +142,7 @@ function fetchIssueFile(productKey, fileName, issueType) {
|
||||
}
|
||||
|
||||
function parseMarkdownIssues(markdownText) {
|
||||
const lines = String(markdownText || '').split(/\r?\n/);
|
||||
const lines = String(stripMarkdownFrontmatter(markdownText) || '').split(/\r?\n/);
|
||||
const issues = [];
|
||||
let currentIssue = null;
|
||||
let inCaveatBlock = false;
|
||||
@@ -188,7 +196,7 @@ function parseMarkdownIssues(markdownText) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentIssue.descriptionLines.push(trimmed);
|
||||
currentIssue.descriptionLines.push(line.trimEnd());
|
||||
});
|
||||
|
||||
finalizeMarkdownIssue(currentIssue, issues);
|
||||
@@ -200,11 +208,12 @@ function finalizeMarkdownIssue(issue, issues) {
|
||||
return;
|
||||
}
|
||||
|
||||
const description = issue.descriptionLines
|
||||
.map(line => line.trimEnd())
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
const description = Array.isArray(issue.descriptionLines)
|
||||
? issue.descriptionLines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
: '';
|
||||
|
||||
let summary = description;
|
||||
if (issue.caveat) {
|
||||
@@ -556,79 +565,25 @@ function markdownSummaryToHtml(summaryText) {
|
||||
}
|
||||
|
||||
function renderMarkdownBodyToHtml(markdownText) {
|
||||
const lines = String(markdownText || '').split(/\r?\n/);
|
||||
const htmlParts = [];
|
||||
let paragraphLines = [];
|
||||
|
||||
function flushParagraph() {
|
||||
if (paragraphLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const text = paragraphLines.join(' ').replace(/\s+/g, ' ').trim();
|
||||
if (text) {
|
||||
htmlParts.push(`<p>${escapeHtml(text)}</p>`);
|
||||
}
|
||||
paragraphLines = [];
|
||||
const input = String(markdownText || '').trim();
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = i + 1 < lines.length ? lines[i + 1].trim() : '';
|
||||
if (isTableRow(trimmed) && isTableSeparator(next)) {
|
||||
flushParagraph();
|
||||
|
||||
const headerCells = parseMarkdownTableRow(trimmed);
|
||||
const rows = [];
|
||||
i += 2;
|
||||
while (i < lines.length && isTableRow(lines[i].trim())) {
|
||||
rows.push(parseMarkdownTableRow(lines[i].trim()));
|
||||
i++;
|
||||
}
|
||||
i -= 1;
|
||||
|
||||
htmlParts.push(renderHtmlTable(headerCells, rows));
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraphLines.push(trimmed);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
return htmlParts.join('');
|
||||
const html = marked.parse(input, {
|
||||
gfm: true,
|
||||
async: false,
|
||||
renderer: summaryMarkdownRenderer
|
||||
});
|
||||
return addIssueTableClasses(typeof html === 'string' ? html : '');
|
||||
}
|
||||
|
||||
function isTableRow(line) {
|
||||
return /\|/.test(line);
|
||||
}
|
||||
|
||||
function isTableSeparator(line) {
|
||||
return /^\|?\s*:?-{3,}:?(\s*\|\s*:?-{3,}:?)*\s*\|?$/.test(line);
|
||||
}
|
||||
|
||||
function parseMarkdownTableRow(line) {
|
||||
const raw = line.trim().replace(/^\|/, '').replace(/\|$/, '');
|
||||
return raw.split('|').map(cell => cell.trim());
|
||||
}
|
||||
|
||||
function renderHtmlTable(headerCells, rows) {
|
||||
const colCount = headerCells.length;
|
||||
const thead = `<thead><tr>${headerCells.map(cell => `<th>${escapeHtml(cell)}</th>`).join('')}</tr></thead>`;
|
||||
const tbodyRows = rows.map(row => {
|
||||
const normalized = [...row];
|
||||
while (normalized.length < colCount) {
|
||||
normalized.push('');
|
||||
}
|
||||
return `<tr>${normalized.slice(0, colCount).map(cell => `<td>${escapeHtml(cell)}</td>`).join('')}</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<table class="issues-table">${thead}<tbody>${tbodyRows}</tbody></table>`;
|
||||
function addIssueTableClasses(html) {
|
||||
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
|
||||
doc.querySelectorAll('table').forEach(table => {
|
||||
table.classList.add('issues-table');
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export function buildIssueMarkdownDocument(payload) {
|
||||
const lines = [];
|
||||
lines.push('---');
|
||||
lines.push(`type: ${payload.type}`);
|
||||
lines.push(`product: ${payload.product}`);
|
||||
lines.push(`version: ${payload.version}`);
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
if (!payload.issues || payload.issues.length === 0) {
|
||||
lines.push('_No issues_');
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
payload.issues.forEach(issue => {
|
||||
lines.push(`## ${issue.id}`);
|
||||
lines.push('');
|
||||
|
||||
if (issue.caveat) {
|
||||
lines.push('```caveat');
|
||||
lines.push(issue.caveat);
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
getIssueDescriptionParagraphs(issue.description).forEach(paragraph => {
|
||||
lines.push(paragraph);
|
||||
lines.push('');
|
||||
});
|
||||
|
||||
if (Array.isArray(issue.platforms) && issue.platforms.length > 0) {
|
||||
lines.push(`Platforms: ${issue.platforms.join(', ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function stripMarkdownFrontmatter(markdownText) {
|
||||
const input = String(markdownText || '').replace(/^\uFEFF/, '');
|
||||
const lines = input.split(/\r?\n/);
|
||||
|
||||
if (lines[0] !== '---') {
|
||||
return input;
|
||||
}
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
if (lines[i] === '---') {
|
||||
return lines.slice(i + 1).join('\n').replace(/^\n+/, '');
|
||||
}
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
function getIssueDescriptionParagraphs(description) {
|
||||
const paragraphs = String(description || '')
|
||||
.split(/\n{2,}/)
|
||||
.map(text => text.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return paragraphs.length > 0 ? paragraphs : [''];
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { buildIssueMarkdownDocument } from './markdown.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadProducts();
|
||||
setupEventListeners();
|
||||
restoreFormState();
|
||||
});
|
||||
|
||||
let activeDownloadUrl = null;
|
||||
const PROCESS_FORM_STATE_KEY = 'bugmedley.process.formState.v1';
|
||||
|
||||
function loadProducts() {
|
||||
fetch('data/products.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const productSelect = document.getElementById('productSelect');
|
||||
const products = Object.keys(data || {});
|
||||
|
||||
products.forEach(product => {
|
||||
const option = document.createElement('option');
|
||||
option.value = product;
|
||||
option.textContent = product;
|
||||
productSelect.appendChild(option);
|
||||
});
|
||||
|
||||
applyPersistedProductSelection();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading products:', error);
|
||||
setParseStatus('Could not load data/products.json', true);
|
||||
});
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
document.getElementById('issuesInput').addEventListener('input', generateJSON);
|
||||
document.getElementById('productSelect').addEventListener('change', generateJSON);
|
||||
document.getElementById('issueTypeSelect').addEventListener('change', generateJSON);
|
||||
document.getElementById('versionInput').addEventListener('input', generateJSON);
|
||||
document.getElementById('copyMarkdownBtn').addEventListener('click', copyToClipboard);
|
||||
|
||||
document.getElementById('productSelect').addEventListener('change', persistFormState);
|
||||
document.getElementById('issueTypeSelect').addEventListener('change', persistFormState);
|
||||
document.getElementById('versionInput').addEventListener('input', persistFormState);
|
||||
}
|
||||
|
||||
function generateJSON() {
|
||||
const product = document.getElementById('productSelect').value;
|
||||
const issueType = document.getElementById('issueTypeSelect').value;
|
||||
const version = document.getElementById('versionInput').value.trim();
|
||||
const inputText = document.getElementById('issuesInput').value;
|
||||
|
||||
persistFormState();
|
||||
|
||||
if (!product || !issueType || !version || !inputText.trim()) {
|
||||
document.getElementById('markdownOutput').value = '';
|
||||
resetDownloadLink();
|
||||
setParseStatus('');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedIssues = parseIssuesInput(inputText);
|
||||
|
||||
if (parsedIssues.length === 0) {
|
||||
document.getElementById('markdownOutput').value = '';
|
||||
resetDownloadLink();
|
||||
setParseStatus('No issues found in pasted input. Make sure it includes Issue ID and Description rows.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = parsedIssues.map(issue => ({
|
||||
id: issue.id,
|
||||
description: issue.description,
|
||||
caveat: issue.caveat || ''
|
||||
}));
|
||||
|
||||
const markdownText = buildIssueMarkdownDocument({
|
||||
type: issueType,
|
||||
product,
|
||||
version,
|
||||
issues
|
||||
});
|
||||
|
||||
document.getElementById('markdownOutput').value = markdownText;
|
||||
updateDownloadLink(markdownText, version);
|
||||
setParseStatus(`Parsed ${issues.length} issues from input.`);
|
||||
}
|
||||
|
||||
function parseIssuesInput(inputText) {
|
||||
const fromHtml = parseIssuesFromHtmlTable(inputText);
|
||||
if (fromHtml.length > 0) {
|
||||
return fromHtml;
|
||||
}
|
||||
|
||||
return parseIssuesFromLinePairs(inputText);
|
||||
}
|
||||
|
||||
function parseIssuesFromHtmlTable(htmlText) {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlText, 'text/html');
|
||||
const table = doc.querySelector('table');
|
||||
|
||||
if (!table) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = table.querySelectorAll(':scope > tr, :scope > tbody > tr, :scope > thead > tr, :scope > tfoot > tr');
|
||||
const issues = [];
|
||||
|
||||
rows.forEach(row => {
|
||||
const cells = row.querySelectorAll('td, th');
|
||||
if (cells.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leftCellText = normalizeWhitespace(cells[0].textContent || '');
|
||||
const issueDetails = extractIssueDetails(cells[1]);
|
||||
const rightCellText = issueDetails.description;
|
||||
|
||||
if (/^issue\s*id$/i.test(leftCellText) || /^description$/i.test(rightCellText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issueIdMatch = leftCellText.match(/\b([A-Z]{2,6}-\d{4,8})\b/i);
|
||||
if (!issueIdMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = issueIdMatch[1].toUpperCase();
|
||||
const description = rightCellText;
|
||||
|
||||
if (!description) {
|
||||
return;
|
||||
}
|
||||
|
||||
issues.push({
|
||||
id,
|
||||
description,
|
||||
caveat: issueDetails.caveat
|
||||
});
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function extractIssueDetails(cell) {
|
||||
const caveatNode = cell.querySelector('tt');
|
||||
const caveat = caveatNode ? normalizeWhitespace(caveatNode.textContent || '') : '';
|
||||
|
||||
if (!caveatNode) {
|
||||
return {
|
||||
description: extractCellText(cell),
|
||||
caveat: ''
|
||||
};
|
||||
}
|
||||
|
||||
const clone = cell.cloneNode(true);
|
||||
const cloneCaveatNode = clone.querySelector('tt');
|
||||
if (cloneCaveatNode) {
|
||||
cloneCaveatNode.remove();
|
||||
}
|
||||
|
||||
let description = extractCellText(clone);
|
||||
description = description.replace(/^\(\s*\)\s*/, '').trim();
|
||||
|
||||
return {
|
||||
description,
|
||||
caveat
|
||||
};
|
||||
}
|
||||
|
||||
function extractCellText(cell) {
|
||||
const clone = cell.cloneNode(true);
|
||||
const nestedTables = Array.from(clone.querySelectorAll('table'));
|
||||
const markdownTables = nestedTables
|
||||
.map(table => convertHtmlTableToMarkdown(table))
|
||||
.filter(Boolean);
|
||||
|
||||
nestedTables.forEach(table => table.remove());
|
||||
|
||||
const blockLikeNodes = clone.querySelectorAll('div, p, li, pre, code');
|
||||
let mainText = '';
|
||||
|
||||
if (blockLikeNodes.length > 0) {
|
||||
const parts = [];
|
||||
blockLikeNodes.forEach(node => {
|
||||
const text = normalizeWhitespace(node.textContent || '');
|
||||
if (text) {
|
||||
parts.push(text);
|
||||
}
|
||||
});
|
||||
|
||||
if (parts.length > 0) {
|
||||
mainText = parts.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainText) {
|
||||
const withLineBreaks = (clone.innerHTML || '')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, ' ');
|
||||
|
||||
mainText = normalizeWhitespace(withLineBreaks).replace(/\s*\n\s*/g, '\n').trim();
|
||||
}
|
||||
|
||||
if (markdownTables.length === 0) {
|
||||
return mainText;
|
||||
}
|
||||
|
||||
if (!mainText) {
|
||||
return markdownTables.join('\n\n');
|
||||
}
|
||||
|
||||
return [mainText, ...markdownTables].join('\n\n');
|
||||
}
|
||||
|
||||
function convertHtmlTableToMarkdown(table) {
|
||||
const rows = Array.from(table.querySelectorAll('tr'))
|
||||
.map(row => Array.from(row.querySelectorAll('th, td')))
|
||||
.filter(cells => cells.length > 0);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const headerCells = rows[0].map(cell => sanitizeTableCellText(cell.textContent || ''));
|
||||
const columnCount = headerCells.length;
|
||||
|
||||
const lines = [];
|
||||
lines.push(`| ${headerCells.join(' | ')} |`);
|
||||
lines.push(`| ${Array(columnCount).fill('---').join(' | ')} |`);
|
||||
|
||||
rows.slice(1).forEach(cells => {
|
||||
const values = cells.map(cell => sanitizeTableCellText(cell.textContent || ''));
|
||||
while (values.length < columnCount) {
|
||||
values.push('');
|
||||
}
|
||||
|
||||
lines.push(`| ${values.slice(0, columnCount).join(' | ')} |`);
|
||||
});
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function sanitizeTableCellText(text) {
|
||||
return normalizeWhitespace(text).replace(/\|/g, '\\|');
|
||||
}
|
||||
|
||||
function parseIssuesFromLinePairs(rawText) {
|
||||
const lines = rawText
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const issues = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i += 2) {
|
||||
if (i + 1 >= lines.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const issueIdMatch = lines[i].match(/\b([A-Z]{2,6}-\d{4,8})\b/i);
|
||||
if (!issueIdMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
issues.push({
|
||||
id: issueIdMatch[1].toUpperCase(),
|
||||
description: lines[i + 1]
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value) {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function setParseStatus(message, isError = false) {
|
||||
const status = document.getElementById('parseStatus');
|
||||
status.textContent = message;
|
||||
status.style.color = isError ? '#b00020' : '#333';
|
||||
}
|
||||
|
||||
function copyToClipboard(event) {
|
||||
const markdownOutput = document.getElementById('markdownOutput');
|
||||
if (!markdownOutput.value) {
|
||||
setParseStatus('Nothing to copy yet.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(markdownOutput.value)
|
||||
.then(() => {
|
||||
const button = event && event.target ? event.target : null;
|
||||
if (!button) {
|
||||
setParseStatus('Copied to clipboard.');
|
||||
return;
|
||||
}
|
||||
|
||||
const originalText = button.textContent;
|
||||
button.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
}, 1200);
|
||||
})
|
||||
.catch(() => {
|
||||
setParseStatus('Clipboard copy failed. Copy manually from preview.', true);
|
||||
});
|
||||
}
|
||||
|
||||
function persistFormState() {
|
||||
const formState = {
|
||||
product: document.getElementById('productSelect').value || '',
|
||||
issueType: document.getElementById('issueTypeSelect').value || '',
|
||||
version: document.getElementById('versionInput').value || ''
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(PROCESS_FORM_STATE_KEY, JSON.stringify(formState));
|
||||
} catch (error) {
|
||||
console.warn('Could not persist process form state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function restoreFormState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROCESS_FORM_STATE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const productSelect = document.getElementById('productSelect');
|
||||
const issueTypeSelect = document.getElementById('issueTypeSelect');
|
||||
const versionInput = document.getElementById('versionInput');
|
||||
|
||||
if (typeof parsed.issueType === 'string') {
|
||||
issueTypeSelect.value = parsed.issueType;
|
||||
}
|
||||
|
||||
if (typeof parsed.version === 'string') {
|
||||
versionInput.value = parsed.version;
|
||||
}
|
||||
|
||||
if (typeof parsed.product === 'string' && productSelect.options.length > 1) {
|
||||
const optionExists = Array.from(productSelect.options).some(option => option.value === parsed.product);
|
||||
if (optionExists) {
|
||||
productSelect.value = parsed.product;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not restore process form state:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function applyPersistedProductSelection() {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROCESS_FORM_STATE_KEY);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed.product !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const productSelect = document.getElementById('productSelect');
|
||||
const optionExists = Array.from(productSelect.options).some(option => option.value === parsed.product);
|
||||
if (optionExists) {
|
||||
productSelect.value = parsed.product;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not apply persisted product selection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDownloadLink(markdownText, version) {
|
||||
const link = document.getElementById('downloadMarkdownLink');
|
||||
resetDownloadLink();
|
||||
|
||||
const blob = new Blob([markdownText], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
activeDownloadUrl = url;
|
||||
|
||||
const dateStamp = new Date().toISOString().slice(0, 10);
|
||||
const safeVersion = String(version || 'version').replace(/[^a-zA-Z0-9.-]/g, '-');
|
||||
|
||||
link.href = url;
|
||||
link.download = `${safeVersion}_${dateStamp}.md`;
|
||||
link.style.pointerEvents = 'auto';
|
||||
link.style.opacity = '1';
|
||||
}
|
||||
|
||||
function resetDownloadLink() {
|
||||
const link = document.getElementById('downloadMarkdownLink');
|
||||
|
||||
if (activeDownloadUrl) {
|
||||
URL.revokeObjectURL(activeDownloadUrl);
|
||||
activeDownloadUrl = null;
|
||||
}
|
||||
|
||||
link.href = '#';
|
||||
link.style.pointerEvents = 'none';
|
||||
link.style.opacity = '0.6';
|
||||
}
|
||||
Reference in New Issue
Block a user