Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/__tests__/components/external-concept-display.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { formatExternalLabel } from '../../utils/external-detection';

describe('external concept label formatting', () => {
it('wraps external concept labels in parentheses', () => {
expect(formatExternalLabel('precision condition', true)).toBe('(precision condition)');
});

it('does not wrap regular concept labels', () => {
expect(formatExternalLabel('measurement unit', false)).toBe('measurement unit');
});

it('handles empty labels', () => {
expect(formatExternalLabel('', true)).toBe('()');
expect(formatExternalLabel('', false)).toBe('');
});

it('does not double-wrap already parenthesized labels', () => {
expect(formatExternalLabel('(precision)', true)).toBe('(precision)');
});
});
45 changes: 45 additions & 0 deletions src/__tests__/content-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,51 @@ describe('renderContent', () => {
expect(result).not.toContain('|===');
});

it('strips AsciiDoc table attribute lines (cols, options)', () => {
const input = 'Intro\n\n[cols="3", options="noheader,unnumbered"]\n|===\n| a | b\n|===';
const result = renderContent(input);
expect(result).not.toContain('[cols=');
expect(result).not.toContain('noheader');
expect(result).toContain('<table class="concept-table">');
});

it('strips cell-span modifiers (.2+, 2+) from cell content', () => {
const input = '|===\n.2+| cell-a | cell-b\n| cell-c\n|===';
const result = renderContent(input);
expect(result).not.toMatch(/\b\.2\+/);
expect(result).toContain('cell-a');
expect(result).toContain('cell-b');
});

it('joins + continuation lines into the same cell with <br>', () => {
const input = '|===\n| english line +\nfrench line | cell-b\n|===';
const result = renderContent(input);
expect(result).toContain('english line<br>french line');
expect(result).toContain('<th>cell-b</th>');
expect(result).not.toMatch(/\s\+\s*\|/);
});

it('groups cells of the same row when continuation is used', () => {
// VIM-style bilingual layout: each `|` after a `+` is a new cell in
// the SAME row, not a new row.
const input = '|===\n.2+| length +\nlongueur | radius +\nrayon\n|===';
const result = renderContent(input);
expect(result).toContain('length');
expect(result).toContain('longueur');
expect(result).toContain('radius');
expect(result).toContain('rayon');
expect(result).toContain('length<br>longueur');
expect(result).toContain('radius<br>rayon');
});

it('preserves rendered math spans inside table cells (no double-escaping)', () => {
const input = '|===\n| length, stem:[l] | radius, stem:[r]\n|===';
const result = renderContent(input);
expect(result).toContain('data-expr="l"');
expect(result).toContain('data-expr="r"');
expect(result).not.toContain('&lt;span');
});

it('resolves URN inline refs via xrefResolver', () => {
const resolver = (uri: string, term: string) => `[${term}→${uri}]`;
const result = renderContent(
Expand Down
57 changes: 41 additions & 16 deletions src/components/GenericRelationList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,7 @@
* decompositions as ISO 704 rake diagrams. Mirror of
* PartitiveRelationList with type label swapped.
*
* Each relation card shows:
* - completeness badge (Complete / Partial)
* - the comprehensive (genus) designation as the card title
* - optional criterion as italic text
* - the rake diagram itself (PartitiveRelationDiagram, reused —
* the line notation is identical for generic rakes)
*
* OIML V 2-200:2010 has ~8 generic hyperedges; 5.1 measurement
* standard alone has 6 criterion groups. This component is the
* primary rendering surface for those datasets.
* External concepts are parenthesized per ISO 704 §5.5.4.3.1.
*/
import type { GenericRelationWire, Manifest } from '../adapters/types';
import { useRouter } from 'vue-router';
Expand All @@ -23,6 +14,7 @@ import { useI18n, locale } from '../i18n';
import { completenessLabel } from '../utils/partitive-relation-styling';
import { presenceLabel, countLabel } from '../utils/partitive-multiplicity';
import { resolveDesignation } from '../utils/resolve-designation';
import { isExternalMember, isExternalComprehensive, formatExternalLabel } from '../utils/external-detection';
import PartitiveRelationDiagram, {
type PartitiveMemberLabeled,
} from './PartitiveRelationDiagram.vue';
Expand All @@ -38,8 +30,41 @@ const store = useVocabularyStore();
const factory = getFactory();
const { t } = useI18n();

function designationFor(uri: string): string {
return resolveDesignation(uri, store, factory, locale.value);
const externalCache = new Map<string, boolean>();

const externalStore = {
lookup(ref: { source?: string | null; id?: string | null }) {
if (!ref?.source && !ref?.id) return null;
for (const adapter of store.datasets.values()) {
for (const entry of adapter.getConcepts() ?? []) {
if (entry && entry.id === ref.id) {
return {
status: entry.status,
related: (entry as any).relatedConcepts ?? (entry as any).related ?? [],
};
}
}
}
return null;
},
};

function isExternalUri(uri: string, kind: 'member' | 'comprehensive'): boolean {
if (externalCache.has(uri)) return externalCache.get(uri)!;
const ref = { source: uri, id: uri.split('/').pop() ?? uri };
let isExt: boolean;
if (kind === 'comprehensive') {
isExt = isExternalComprehensive({ comprehensive: ref, members: [] }, externalStore);
} else {
isExt = isExternalMember({ ref }, externalStore);
}
externalCache.set(uri, isExt);
return isExt;
}

function designationFor(uri: string, kind: 'member' | 'comprehensive' = 'member'): string {
const label = resolveDesignation(uri, store, factory, locale.value);
return formatExternalLabel(label, isExternalUri(uri, kind));
}

/** Resolve a LocalizedString delimitingCharacteristic to the current locale. */
Expand All @@ -53,7 +78,7 @@ function memberLabel(member: GenericRelationWire['members'][number]): PartitiveM
presence: member.presence,
count: member.count,
isDelimiting: false,
label: designationFor(member.uri),
label: designationFor(member.uri, 'member'),
};
}
</script>
Expand All @@ -70,7 +95,7 @@ function memberLabel(member: GenericRelationWire['members'][number]): PartitiveM
{{ completenessLabel(rel.completeness) }}
</span>
<span class="badge badge-type">{{ t('relations.generic') }}</span>
<h4 class="comprehensive">{{ designationFor(rel.comprehensive) }}</h4>
<h4 class="comprehensive">{{ designationFor(rel.comprehensive, 'comprehensive') }}</h4>
</header>

<p v-if="rel.criterion" class="criterion">
Expand All @@ -79,13 +104,13 @@ function memberLabel(member: GenericRelationWire['members'][number]): PartitiveM

<PartitiveRelationDiagram
:partitives="rel.members.map(memberLabel)"
:comprehensive-label="designationFor(rel.comprehensive)"
:comprehensive-label="designationFor(rel.comprehensive, 'comprehensive')"
:completeness="rel.completeness"
/>

<ul class="member-presence-list" aria-label="Member delimiting characteristics">
<li v-for="(m, j) in rel.members" :key="j">
<span class="member-label">{{ designationFor(m.uri) }}</span>
<span class="member-label">{{ designationFor(m.uri, 'member') }}</span>
<span
v-if="m.delimitingCharacteristic"
class="member-delimiting-characteristic"
Expand Down
150 changes: 140 additions & 10 deletions src/utils/content-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,17 +98,138 @@ function mathPlaceholder(expr: string, format: string, bold: boolean): string {

// ── Block transforms ─────────────────────────────────────────────────────

/**
* AsciiDoc table attribute line, e.g.
* [cols="3", options="noheader,unnumbered"]
* [format="dsv"]
* Lives immediately before the `|===` opener. The renderer does not honour
* these attributes (no column sizing, no header suppression), so we strip
* them to avoid leaking as literal text in the output.
*/
const ASCII_DOC_TABLE_ATTR_RE = /\n?\[[^\]\n]+\]\s*\n(?=\n?\|===)/g;

/**
* Pre-row span/alignment modifier prefix that can appear before a leading
* pipe:
* .2+ → cell spans 2 columns
* .3+ → cell spans 3 columns
* 2+ → cell spans 2 rows
* a> → horizontal alignment
* The renderer does not honour spans, so we strip the modifier.
*/
const ASCII_DOC_LEADING_SPAN_PIPE_RE = /^[.\d+]+[+>]+\s*\|/;

/**
* Cell-span modifier prefix that can appear inside a cell (after the pipe):
* .2+ cell content → cell content
* Kept separate from ASCII_DOC_LEADING_SPAN_PIPE_RE because the leading
* variant consumes the pipe whereas this one only consumes the modifier.
*/
const ASCII_DOC_CELL_SPAN_RE = /^[.]\d+[+]|[.]\d+>[>]?\s*/g;

/**
* Trailing cell-continuation marker ` +` at the end of a row line means
* "the next physical line continues this cell". The renderer joins such
* lines into the same cell with a `<br>` separator.
*/
const ASCII_DOC_CELL_CONTINUATION_RE = /\s\+\s*$/;

function stripAsciiDocTableAttributes(text: string): string {
return text.replace(ASCII_DOC_TABLE_ATTR_RE, '\n');
}

function convertAsciiDocTables(text: string): string {
return text.replace(/\n?\|===\n([\s\S]*?)\n\|===/g, (_: string, body: string) => {
const rows: string[] = body.split('\n').filter((line: string) => line.trim() !== '');
if (!rows.length) return '';
const prepared = stripAsciiDocTableAttributes(text);
return prepared.replace(/\n?\|===\n([\s\S]*?)\n\|===/g, (_: string, body: string) => {
const lines: string[] = body.split('\n');
if (!lines.some((l: string) => l.trim() !== '')) return '';

// State machine for joining `+` continuation lines into the same cell
// and grouping cells into rows. Two formats are supported:
//
// 1. Simple one-row-per-line:
// | a | b | c
// | d | e | f
// Each leading `|` starts a new row.
//
// 2. Continuation-based multi-cell rows (used by VIM and other
// bilingual sources):
// | english-line +
// french line | next-cell +
// more of next-cell
// A `|` at the start of a line, AFTER the current row has used
// a `+` continuation, starts a new CELL in the SAME row.
//
// The disambiguation state is `rowHasContinuation`: once any line in
// the current row has used `+`, subsequent leading `|` lines are new
// cells in that row. Otherwise (simple format), they are new rows.
const rows: string[][] = [];
let currentRow: string[] | null = null;
let rowHasContinuation = false;
let prevLineEndedWithContinuation = false;

const flushRow = () => {
if (currentRow !== null && currentRow.length > 0) rows.push(currentRow);
currentRow = null;
rowHasContinuation = false;
prevLineEndedWithContinuation = false;
};

for (const line of lines) {
if (line.trim() === '') {
flushRow();
continue;
}

const parsedRows: string[][] = rows.map((row: string) => {
const cellText = row.replace(/^\s*\|/, '').trim();
const cells = cellText.split(/\s*\|\s*/).map((c: string) => c.trim()).filter((c: string) => c !== '');
return cells;
}).filter((r: string[]) => r.length > 0);
const startsWithPipe = ASCII_DOC_LEADING_SPAN_PIPE_RE.test(line) || /^\s*\|/.test(line);
const endsWithContinuation = ASCII_DOC_CELL_CONTINUATION_RE.test(line);
const cleaned = line
.replace(ASCII_DOC_LEADING_SPAN_PIPE_RE, '|')
.replace(ASCII_DOC_CELL_CONTINUATION_RE, '')
.trim();

if (startsWithPipe && !rowHasContinuation) {
// New row in simple format.
flushRow();
const cellText = cleaned.replace(/^\s*\|/, '').trim();
const newCells = cellText.split(/\s*\|\s*/).map(stripCellSpanModifier).filter(c => c !== '');
currentRow = newCells;
} else if (startsWithPipe && rowHasContinuation) {
// New cell in the same row (continuation format).
if (currentRow === null) currentRow = [];
const cellText = cleaned.replace(/^\s*\|/, '').trim();
const newCells = cellText.split(/\s*\|\s*/).map(stripCellSpanModifier).filter(c => c !== '');
currentRow.push(...newCells);
} else if (currentRow !== null && prevLineEndedWithContinuation) {
// Continuation of the previous cell — append with <br>.
// The continuation line may itself contain `|`-separated cells,
// so split it; the first piece joins the last cell, the rest
// become new cells in the same row.
const pieces = cleaned.split(/\s*\|\s*/).map(stripCellSpanModifier).filter(c => c !== '');
if (pieces.length > 0 && currentRow.length > 0) {
currentRow[currentRow.length - 1] = `${currentRow[currentRow.length - 1]}<br>${pieces[0]}`;
if (pieces.length > 1) currentRow.push(...pieces.slice(1));
} else if (pieces.length > 0) {
currentRow.push(pieces[0]);
if (pieces.length > 1) currentRow.push(...pieces.slice(1));
}
} else {
// Stray non-pipe line outside any continuation — treat as a new
// single-cell row so the content is not lost.
flushRow();
currentRow = [cleaned];
}

if (endsWithContinuation) {
rowHasContinuation = true;
prevLineEndedWithContinuation = true;
} else {
prevLineEndedWithContinuation = false;
}
}
flushRow();

const parsedRows: string[][] = rows.filter((r: string[]) => r.length > 0);
if (!parsedRows.length) return '';

const maxCols = Math.max(...parsedRows.map((r: string[]) => r.length));
Expand All @@ -117,15 +238,24 @@ function convertAsciiDocTables(text: string): string {
return r;
});

const thead = normalized[0].map((c: string) => `<th>${escapeHtml(c)}</th>`).join('');
// Cells may already contain rendered math spans (from stage 1) and
// inline formatting (italic, subscript) from stage 3. escapeHtml
// would double-escape the angle brackets inside those spans and
// cause the browser to render them as literal text. Pass cell
// content through unchanged.
const thead = normalized[0].map((c: string) => `<th>${c}</th>`).join('');
const tbody = normalized.slice(1).map((r: string[]) =>
`<tr>${r.map((c: string) => `<td>${escapeHtml(c)}</td>`).join('')}</tr>`
`<tr>${r.map((c: string) => `<td>${c}</td>`).join('')}</tr>`
).join('');

return `\n<table class="concept-table"><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table>`;
});
}

function stripCellSpanModifier(cell: string): string {
return cell.replace(ASCII_DOC_CELL_SPAN_RE, '').trim();
}

function convertLists(text: string): string {
let result = text.replace(/(?:^|\n)((?:[ \t]*\* [^\n]+)(?:\n[ \t]*\* [^\n]+)*)/g, (_, block) => {
if (/^\*stem:\[/.test(block.trimStart())) return _;
Expand Down
4 changes: 3 additions & 1 deletion src/utils/external-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,5 +97,7 @@ export function formatExternalLabel(
label: string,
isExternal: boolean,
): string {
return isExternal ? `(${label})` : label;
if (!isExternal) return label;
if (label.startsWith('(') && label.endsWith(')')) return label;
return `(${label})`;
}