Allow safe rich text in market multiline fields

This commit is contained in:
gitea_admin committed 2026-09-21 15:30:13 +02:00
1 parent 73e2144571
commit c25d3f1b3c
4 files changed
+141 -17

No files matched your search

@@ -25,14 +25,14 @@ Der Token wird nicht in dieser Dokumentation oder im Repository gespeichert.
## Dokumentlinks in Marktdurchführungen ## Dokumentlinks in Marktdurchführungen
Wenn Hermes Informationen aus einer Datei verarbeitet und diese in einer Wenn Hermes Informationen aus einer Datei verarbeitet und diese in einer
Marktdurchführung speichert, muss Hermes die Quelldatei als vollständige Marktdurchführung speichert, muss Hermes die Quelldatei als Link im Feld
HTTPS-URL in das Feld `notes` aufnehmen. Der bestehende Text darf erhalten `notes` aufnehmen. Der bestehende Text darf erhalten bleiben; die Position im
bleiben; die Position im Freitext ist frei wählbar. Freitext ist frei wählbar.
Die URL verwendet dieses Format: Der Link verwendet dieses Format:
```text ```text
https://erpnaurua.imhochrain.ch/files.php?path=<RFC-3986-url-encodierter-relativer-Pfad> <a href="https://erpnaurua.imhochrain.ch/files.php?path=<RFC-3986-url-encodierter-relativer-Pfad>">Dokumentname</a>
``` ```
Der Parameter `path` ist relativ zum Ordner `Hermes Naurua` und enthält nicht Der Parameter `path` ist relativ zum Ordner `Hermes Naurua` und enthält nicht
@@ -41,11 +41,13 @@ Beispiel:
```text ```text
Quelldatei: Hermes Naurua/Marktmanagement/2026 Bellevue Noël/Standmietvertrag_Naurua.pdf Quelldatei: Hermes Naurua/Marktmanagement/2026 Bellevue Noël/Standmietvertrag_Naurua.pdf
Link: https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2F2026%20Bellevue%20No%C3%ABl%2FStandmietvertrag_Naurua.pdf Link: <a href="https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2F2026%20Bellevue%20No%C3%ABl%2FStandmietvertrag_Naurua.pdf">Standmietvertrag_Naurua.pdf</a>
``` ```
Leerzeichen, Umlaute und sonstige Sonderzeichen müssen vollständig URL-encodiert Leerzeichen, Umlaute und sonstige Sonderzeichen im URL-Parameter müssen
werden. Die ERP-Anzeige macht ausschließlich diese Dokument-URLs klickbar. Der vollständig URL-encodiert werden. Im Feld `notes` sind ausschließlich `<a>`
für solche internen Dokumentlinks sowie `<br>` und `<strong>` erlaubt. Andere
HTML-Tags, externe Links und nicht erlaubte URL-Schemata werden entfernt. Der
Link ist schreibgeschützt und erfordert eine aktive ERP-Anmeldung. Link ist schreibgeschützt und erfordert eine aktive ERP-Anmeldung.
Die Route benötigt auf dem ERP-Webserver die Konfiguration Die Route benötigt auf dem ERP-Webserver die Konfiguration
@@ -69,9 +69,10 @@ Modulberechtigung.
- Alle Schreibvorgänge laufen über die interne Modul-API. - Alle Schreibvorgänge laufen über die interne Modul-API.
- Hermes darf bei einem neuen Markt nur eine bestehende Kategorie referenzieren. - Hermes darf bei einem neuen Markt nur eine bestehende Kategorie referenzieren.
- Wenn Hermes Informationen aus einer Datei verarbeitet und in einer - Wenn Hermes Informationen aus einer Datei verarbeitet und in einer
Marktdurchführung speichert, muss die Quelldatei als vollständige HTTPS-URL Marktdurchführung speichert, muss die Quelldatei als interner `<a>`-Link im
im Feld `notes` referenziert werden. Die Position im bestehenden Freitext ist Feld `notes` referenziert werden. Zusätzlich sind in mehrzeiligen Feldern
frei wählbar. `<br>` und `<strong>` erlaubt. Die Position im bestehenden Freitext ist frei
wählbar; andere HTML-Tags und externe Links sind nicht zulässig.
- Unsichere Erkennung wird nicht durch eine zusätzliche App-Freigabelogik - Unsichere Erkennung wird nicht durch eine zusätzliche App-Freigabelogik
behandelt; die fachliche Qualitätssicherung liegt im Hermes-Training. behandelt; die fachliche Qualitätssicherung liegt im Hermes-Training.
+125 -4
View File
@@ -18,6 +18,120 @@ const MARKET_OCCURRENCE_DAY_INHERITED_FIELDS = [
'delivery_start_time', 'delivery_end_time', 'delivery_start_time', 'delivery_end_time',
]; ];
const MARKET_MANAGEMENT_MULTILINE_FIELDS = [
'address', 'notes', 'other_infrastructure', 'own_stand_requirements',
'travel_address', 'parking_options', 'delivery_access', 'storage_options',
'travel_notes', 'cancellation_terms', 'revenue_share_terms',
'other_participation_conditions', 'observations', 'cost_items_text',
'description',
];
function market_management_is_allowed_document_url(string $url): bool
{
$parts = parse_url($url);
if (!is_array($parts)
|| ($parts['scheme'] ?? '') !== 'https'
|| ($parts['host'] ?? '') !== 'erpnaurua.imhochrain.ch'
|| ($parts['path'] ?? '') !== '/files.php'
|| isset($parts['port'], $parts['user'], $parts['pass'], $parts['fragment'])
|| !preg_match('/^path=([^&]+)$/', (string) ($parts['query'] ?? ''), $matches)) {
return false;
}
$relativePath = rawurldecode($matches[1]);
if ($relativePath === '' || str_contains($relativePath, "\0") || str_contains($relativePath, '\\') || str_starts_with($relativePath, '/')) {
return false;
}
foreach (explode('/', $relativePath) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
function market_management_sanitize_multiline_html(?string $value): ?string
{
if ($value === null || $value === '') {
return $value;
}
$tokens = preg_split('/(<\/?a\b[^>]*>|<br\s*\/?>|<\/?strong\s*>)/iu', $value, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($tokens === false) {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$result = '';
$anchorOpen = false;
foreach ($tokens as $token) {
if (preg_match('/^<br\s*\/?>$/iu', $token)) {
$result .= '<br>';
continue;
}
if (preg_match('/^<strong\s*>$/iu', $token)) {
$result .= '<strong>';
continue;
}
if (preg_match('/^<\/strong\s*>$/iu', $token)) {
$result .= '</strong>';
continue;
}
if (preg_match('/^<\/a\s*>$/iu', $token)) {
if ($anchorOpen) {
$result .= '</a>';
$anchorOpen = false;
} else {
$result .= htmlspecialchars($token, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
continue;
}
if (preg_match('/^<a\b[^>]*\bhref\s*=\s*(["\'])(.*?)\1[^>]*>$/isu', $token, $matches)
&& market_management_is_allowed_document_url($matches[2])) {
if ($anchorOpen) {
$result .= '</a>';
}
$href = htmlspecialchars($matches[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$result .= '<a class="sg-hyperlink" href="' . $href . '" target="_blank" rel="noopener">';
$anchorOpen = true;
continue;
}
$result .= htmlspecialchars($token, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if ($anchorOpen) {
$result .= '</a>';
}
return $result;
}
function market_management_sanitize_multiline_fields(array &$record): void
{
foreach (MARKET_MANAGEMENT_MULTILINE_FIELDS as $field) {
if (array_key_exists($field, $record) && is_string($record[$field])) {
$record[$field] = market_management_sanitize_multiline_html($record[$field]);
}
}
}
function market_management_sanitize_occurrence_payload(array &$occurrence): void
{
market_management_sanitize_multiline_fields($occurrence);
foreach ($occurrence['days'] ?? [] as &$day) {
if (is_array($day)) {
market_management_sanitize_multiline_fields($day);
}
}
unset($day);
foreach ($occurrence['cost_items'] ?? [] as &$costItem) {
if (is_array($costItem)) {
market_management_sanitize_multiline_fields($costItem);
}
}
unset($costItem);
}
function market_management_list_organizers(PDO $pdo): array function market_management_list_organizers(PDO $pdo): array
{ {
return $pdo->query( return $pdo->query(
@@ -482,6 +596,7 @@ function market_management_get_market(PDO $pdo, int $marketId, bool $includeArch
$costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC'); $costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC');
$costs->execute([':occurrence_id' => $occurrenceId]); $costs->execute([':occurrence_id' => $occurrenceId]);
$occurrence['cost_items'] = $costs->fetchAll(); $occurrence['cost_items'] = $costs->fetchAll();
market_management_sanitize_occurrence_payload($occurrence);
} }
unset($occurrence); unset($occurrence);
return $market; return $market;
@@ -721,6 +836,9 @@ function market_management_write_fields(array $input, array $definitions): array
'nullable_id' => market_management_optional_id($input, $field), 'nullable_id' => market_management_optional_id($input, $field),
default => throw new LogicException('Unbekannter Feldtyp.'), default => throw new LogicException('Unbekannter Feldtyp.'),
}; };
if ($type === 'string' && in_array($field, MARKET_MANAGEMENT_MULTILINE_FIELDS, true)) {
$value = market_management_sanitize_multiline_html($value);
}
$fields[] = $field . ' = :' . $field; $fields[] = $field . ' = :' . $field;
$params[':' . $field] = $value; $params[':' . $field] = $value;
} }
@@ -982,6 +1100,7 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc
break; break;
} }
} }
market_management_sanitize_occurrence_payload($occurrence);
$costSummary = $pdo->prepare( $costSummary = $pdo->prepare(
'SELECT COALESCE(SUM(gross_amount), 0) AS gross_amount, 'SELECT COALESCE(SUM(gross_amount), 0) AS gross_amount,
COALESCE(SUM(net_amount), 0) AS net_amount, COALESCE(SUM(net_amount), 0) AS net_amount,
@@ -1330,7 +1449,7 @@ function market_management_create_cost_item(PDO $pdo, array $input): array
$stmt->execute([ $stmt->execute([
':occurrence_id' => $occurrenceId, ':occurrence_id' => $occurrenceId,
':cost_type_id' => $costTypeId, ':cost_type_id' => $costTypeId,
':description' => market_management_optional_string($input, 'description'), ':description' => market_management_sanitize_multiline_html(market_management_optional_string($input, 'description')),
':gross_amount' => $grossAmount, ':gross_amount' => $grossAmount,
':tax_status' => $taxStatus, ':tax_status' => $taxStatus,
':tax_rate' => $taxRate, ':tax_rate' => $taxRate,
@@ -1381,7 +1500,9 @@ function market_management_update_cost_item(PDO $pdo, int $costItemId, array $in
$sets[] = $field . ' = :' . $field; $sets[] = $field . ' = :' . $field;
$params[':' . $field] = $type === 'number' $params[':' . $field] = $type === 'number'
? market_management_optional_number($input, $field) ? market_management_optional_number($input, $field)
: market_management_optional_string($input, $field); : ($field === 'description'
? market_management_sanitize_multiline_html(market_management_optional_string($input, $field))
: market_management_optional_string($input, $field));
} }
if (isset($params[':tax_status']) && !in_array($params[':tax_status'], ['tax_free', 'taxable'], true)) { if (isset($params[':tax_status']) && !in_array($params[':tax_status'], ['tax_free', 'taxable'], true)) {
throw new InvalidArgumentException('Ungültiger Steuerstatus.'); throw new InvalidArgumentException('Ungültiger Steuerstatus.');
@@ -1418,7 +1539,7 @@ function market_management_update_occurrence_simple_costs(PDO $pdo, int $occurre
$item = market_management_create_cost_item($pdo, [ $item = market_management_create_cost_item($pdo, [
'occurrence_id' => $occurrenceId, 'occurrence_id' => $occurrenceId,
'cost_type_id' => market_management_get_cost_type_id($pdo, 'stand_fee'), 'cost_type_id' => market_management_get_cost_type_id($pdo, 'stand_fee'),
'description' => array_key_exists('cost_items_text', $input) ? market_management_optional_string($input, 'cost_items_text') : null, 'description' => array_key_exists('cost_items_text', $input) ? market_management_sanitize_multiline_html(market_management_optional_string($input, 'cost_items_text')) : null,
'gross_amount' => $grossAmount, 'gross_amount' => $grossAmount,
'tax_status' => $taxStatus, 'tax_status' => $taxStatus,
'tax_rate' => $taxRate, 'tax_rate' => $taxRate,
@@ -1429,7 +1550,7 @@ function market_management_update_occurrence_simple_costs(PDO $pdo, int $occurre
$costFields['gross_amount'] = market_management_optional_number($input, 'gross_cost'); $costFields['gross_amount'] = market_management_optional_number($input, 'gross_cost');
} }
if (array_key_exists('cost_items_text', $input)) { if (array_key_exists('cost_items_text', $input)) {
$costFields['description'] = market_management_optional_string($input, 'cost_items_text'); $costFields['description'] = market_management_sanitize_multiline_html(market_management_optional_string($input, 'cost_items_text'));
} }
if (array_key_exists('tax_status', $input)) { if (array_key_exists('tax_status', $input)) {
$costFields['tax_status'] = trim((string) $input['tax_status']); $costFields['tax_status'] = trim((string) $input['tax_status']);
+2 -2
View File
@@ -1152,14 +1152,14 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
echo " const getCurrentLimit = () => parseInt(getTable()?.dataset.marketsLimit || '20', 10);"; echo " const getCurrentLimit = () => parseInt(getTable()?.dataset.marketsLimit || '20', 10);";
echo " const getPageSize = () => parseInt(getTable()?.dataset.marketsPageSize || '20', 10);"; echo " const getPageSize = () => parseInt(getTable()?.dataset.marketsPageSize || '20', 10);";
echo " const marketEscape = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));"; echo " const marketEscape = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " const marketDisplay = (value) => { if (value === null || value === undefined || value === '') { return 'Nicht hinterlegt'; } const escaped = marketEscape(value); const linked = escaped.replace(/https:\\/\\/erpnaurua\\.imhochrain\\.ch\\/files\\.php\\?path=[^\\s<]+/g, (url) => '<a class=\"sg-hyperlink\" href=\"' + url + '\" target=\"_blank\" rel=\"noopener\">' + url + '</a>'); return linked.replace(/\\r?\\n/g, '<br>'); };"; echo " const marketDisplay = (value) => { if (value === null || value === undefined || value === '') { return 'Nicht hinterlegt'; } const escaped = marketEscape(value); const safeMarkup = escaped.replace(/&lt;br&gt;/g, '<br>').replace(/&lt;strong&gt;/g, '<strong>').replace(/&lt;\\/strong&gt;/g, '</strong>').replace(/&lt;a class=&quot;sg-hyperlink&quot; href=&quot;(https:\\/\\/erpnaurua\\.imhochrain\\.ch\\/files\\.php\\?path=[^&quot;]+)&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;/g, '<a class=\"sg-hyperlink\" href=\"$1\" target=\"_blank\" rel=\"noopener\">').replace(/&lt;\\/a&gt;/g, '</a>'); return safeMarkup.replace(/\\r?\\n/g, '<br>'); };";
echo " const marketBoolean = (value) => value === true || value === 'true' || value === 1 || value === '1' ? 'Ja' : value === false || value === 'false' || value === 0 || value === '0' ? 'Nein' : 'Nicht hinterlegt';"; echo " const marketBoolean = (value) => value === true || value === 'true' || value === 1 || value === '1' ? 'Ja' : value === false || value === 'false' || value === 0 || value === '0' ? 'Nein' : 'Nicht hinterlegt';";
echo " const marketMoney = (value) => value === null || value === undefined || value === '' ? 'Nicht hinterlegt' : 'CHF ' + Number(value).toFixed(2);"; echo " const marketMoney = (value) => value === null || value === undefined || value === '' ? 'Nicht hinterlegt' : 'CHF ' + Number(value).toFixed(2);";
echo " const marketContactName = (contact) => { if (!contact) { return 'Nicht hinterlegt'; } return marketDisplay([contact.first_name, contact.last_name].filter(Boolean).join(' ')); };"; echo " const marketContactName = (contact) => { if (!contact) { return 'Nicht hinterlegt'; } return marketDisplay([contact.first_name, contact.last_name].filter(Boolean).join(' ')); };";
echo " const marketDate = (value) => { if (!value) { return 'Nicht hinterlegt'; } const parts = String(value).split('-'); return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' + parts[0] : marketEscape(value); };"; echo " const marketDate = (value) => { if (!value) { return 'Nicht hinterlegt'; } const parts = String(value).split('-'); return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' + parts[0] : marketEscape(value); };";
echo " const marketStatusLabel = (value) => ({ open: 'Offen', waitlist: 'Warteliste', registered: 'Angemeldet', confirmation_pending: 'Bestätigung ausstehend', confirmed: 'Bestätigt', cancelled: 'Abgesagt', completed: 'Abgeschlossen' }[value] || value || 'Nicht hinterlegt');"; echo " const marketStatusLabel = (value) => ({ open: 'Offen', waitlist: 'Warteliste', registered: 'Angemeldet', confirmation_pending: 'Bestätigung ausstehend', confirmed: 'Bestätigt', cancelled: 'Abgesagt', completed: 'Abgeschlossen' }[value] || value || 'Nicht hinterlegt');";
echo " const marketStatusSegment = (value) => ({ waitlist: 'sg-card-segment--signal-red', registered: 'sg-card-segment--signal-yellow', confirmation_pending: 'sg-card-segment--signal-yellow', confirmed: 'sg-card-segment--signal-green' }[value] || 'sg-card-segment--gray');"; echo " const marketStatusSegment = (value) => ({ waitlist: 'sg-card-segment--signal-red', registered: 'sg-card-segment--signal-yellow', confirmation_pending: 'sg-card-segment--signal-yellow', confirmed: 'sg-card-segment--signal-green' }[value] || 'sg-card-segment--gray');";
echo " const marketRow = (label, value) => '<div class=\"market-detail-row\"><dt>' + marketEscape(label) + '</dt><dd>' + marketDisplay(value) + '</dd></div>';"; echo " const marketRow = (label, value, richText = false) => '<div class=\"market-detail-row\"><dt>' + marketEscape(label) + '</dt><dd>' + (richText ? marketRichDisplay(value) : marketDisplay(value)) + '</dd></div>';";
echo " const initializeMunicipalityFields = (scope) => { scope.querySelectorAll('select[name=\"municipality_reference_id\"]').forEach((select) => { if (!select.querySelector('option[value=\"__new__\"]')) { const option = document.createElement('option'); option.value = '__new__'; option.textContent = 'Neue Gemeinde'; select.append(option); } const form = select.closest('form'); if (!form) { return; } let fields = form.querySelector('[data-market-new-municipality]'); if (!fields) { fields = document.createElement('div'); fields.className = 'sg-form-sections-card__field-group'; fields.dataset.marketNewMunicipality = ''; fields.hidden = true; fields.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Gemeinde</span><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" name=\"municipality_name\" data-market-new-municipality-name></label>'; select.closest('.sg-form-sections-card__field-group')?.after(fields); } const sync = () => { const visible = select.value === '__new__'; fields.hidden = !visible; const input = fields.querySelector('[data-market-new-municipality-name]'); if (input) { input.disabled = !visible; input.setAttribute('aria-disabled', String(!visible)); } }; if (select.dataset.municipalityBound !== 'true') { select.addEventListener('change', sync); select.dataset.municipalityBound = 'true'; } sync(); }); };"; echo " const initializeMunicipalityFields = (scope) => { scope.querySelectorAll('select[name=\"municipality_reference_id\"]').forEach((select) => { if (!select.querySelector('option[value=\"__new__\"]')) { const option = document.createElement('option'); option.value = '__new__'; option.textContent = 'Neue Gemeinde'; select.append(option); } const form = select.closest('form'); if (!form) { return; } let fields = form.querySelector('[data-market-new-municipality]'); if (!fields) { fields = document.createElement('div'); fields.className = 'sg-form-sections-card__field-group'; fields.dataset.marketNewMunicipality = ''; fields.hidden = true; fields.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Gemeinde</span><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" name=\"municipality_name\" data-market-new-municipality-name></label>'; select.closest('.sg-form-sections-card__field-group')?.after(fields); } const sync = () => { const visible = select.value === '__new__'; fields.hidden = !visible; const input = fields.querySelector('[data-market-new-municipality-name]'); if (input) { input.disabled = !visible; input.setAttribute('aria-disabled', String(!visible)); } }; if (select.dataset.municipalityBound !== 'true') { select.addEventListener('change', sync); select.dataset.municipalityBound = 'true'; } sync(); }); };";
echo " const syncMunicipalityReadValue = (scope) => { const drawer = scope.querySelector('[data-market-drawer]'); if (!drawer?.dataset.marketPayload) { return; } let payload = {}; try { payload = JSON.parse(drawer.dataset.marketPayload); } catch (error) { return; } const value = payload.occurrence?.municipality_name || 'Nicht hinterlegt'; scope.querySelectorAll('.market-detail-row').forEach((row) => { if (row.querySelector('dt')?.textContent?.trim() !== 'Gemeinde') { return; } const output = row.querySelector('dd'); if (output && output.textContent !== value) { output.textContent = value; } }); };"; echo " const syncMunicipalityReadValue = (scope) => { const drawer = scope.querySelector('[data-market-drawer]'); if (!drawer?.dataset.marketPayload) { return; } let payload = {}; try { payload = JSON.parse(drawer.dataset.marketPayload); } catch (error) { return; } const value = payload.occurrence?.municipality_name || 'Nicht hinterlegt'; scope.querySelectorAll('.market-detail-row').forEach((row) => { if (row.querySelector('dt')?.textContent?.trim() !== 'Gemeinde') { return; } const output = row.querySelector('dd'); if (output && output.textContent !== value) { output.textContent = value; } }); };";
echo " const marketCard = (title, body, headerClass = 'sg-card-segment--darkgreen', actions = '', scope = '', id = '', values = null) => { const header = '<header class=\"sg-card-segment sg-card-segment--header ' + headerClass + ' sg-object-card__header\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">' + title + '</div>' + (actions ? '<div class=\"sg-component-row\">' + actions + '</div>' : '') + '</header>'; return '<article class=\"sg-card sg-card--content-card market-detail-card\" data-component=\"content-card\"' + (scope ? ' data-shared-editable-action-card' : '') + '>' + header + '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\"' + (scope ? ' data-shared-editable-card-read' : '') + '>' + body + '</div>' + (scope ? '<div class=\"sg-card-segment sg-card-segment--body sg-card-segment--gray\" data-shared-editable-card-form hidden>' + marketEditForm(scope, id, values) + '</div>' : '') + '</article>'; };"; echo " const marketCard = (title, body, headerClass = 'sg-card-segment--darkgreen', actions = '', scope = '', id = '', values = null) => { const header = '<header class=\"sg-card-segment sg-card-segment--header ' + headerClass + ' sg-object-card__header\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">' + title + '</div>' + (actions ? '<div class=\"sg-component-row\">' + actions + '</div>' : '') + '</header>'; return '<article class=\"sg-card sg-card--content-card market-detail-card\" data-component=\"content-card\"' + (scope ? ' data-shared-editable-action-card' : '') + '>' + header + '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\"' + (scope ? ' data-shared-editable-card-read' : '') + '>' + body + '</div>' + (scope ? '<div class=\"sg-card-segment sg-card-segment--body sg-card-segment--gray\" data-shared-editable-card-form hidden>' + marketEditForm(scope, id, values) + '</div>' : '') + '</article>'; };";