Skip to content

Commit afad35c

Browse files
wickedevclaude
andcommitted
fix: render text and links together in mixed content lines (#14)
When a line contains both plain text and a quoted link (e.g., "Don't have an account? "Sign up""), both parts are now correctly parsed and rendered. Previously, only the link was rendered and the plain text before/after it was lost. The fix adds link pattern detection to splitInlineSegments(), which now recognizes quoted text as LinkSegment similar to how it handles button patterns. For mixed content, elements are wrapped in a Row. For standalone links, they pass through the ParserRegistry to use LinkParser's existing slugify logic for consistent ID generation. Tests added: - parses both text and link from mixed text-link line - parses link at start followed by text - parses text with link in middle 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ff0eff4 commit afad35c

File tree

2 files changed

+206
-3
lines changed

2 files changed

+206
-3
lines changed

src/index.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,148 @@ describe('onSceneChange callback (Issue #2)', () => {
251251
});
252252
});
253253

254+
describe('mixed text and link content (Issue #14)', () => {
255+
// Regression test for issue #14: Text before link not rendering in mixed text-link line
256+
// When a line contains both plain text and a link (e.g., "Don't have an account? "Sign up""),
257+
// both parts should be rendered, not just the link.
258+
259+
const mixedTextLinkWireframe = `
260+
@scene: test
261+
262+
+---------------------------------------+
263+
| Don't have an account? "Sign up" |
264+
+---------------------------------------+
265+
`;
266+
267+
test('parses both text and link from mixed text-link line', () => {
268+
const result = parse(mixedTextLinkWireframe);
269+
270+
expect(result.success).toBe(true);
271+
if (result.success) {
272+
expect(result.ast.scenes).toHaveLength(1);
273+
const scene = result.ast.scenes[0];
274+
275+
// Should have elements (likely a Row containing Text and Link)
276+
expect(scene.elements.length).toBeGreaterThan(0);
277+
278+
// Helper to recursively find elements
279+
const findElements = (elements: any[], tags: string[]): any[] => {
280+
const found: any[] = [];
281+
for (const el of elements) {
282+
if (tags.includes(el.TAG)) {
283+
found.push(el);
284+
}
285+
if (el.children) {
286+
found.push(...findElements(el.children, tags));
287+
}
288+
}
289+
return found;
290+
};
291+
292+
// Find all Text and Link elements
293+
const textElements = findElements(scene.elements, ['Text']);
294+
const linkElements = findElements(scene.elements, ['Link']);
295+
296+
// Should have at least one Text element containing "Don't have an account?"
297+
const hasAccountText = textElements.some(
298+
(t) => t.content && t.content.includes("Don't have an account?")
299+
);
300+
expect(hasAccountText).toBe(true);
301+
302+
// Should have the "Sign up" link
303+
const hasSignUpLink = linkElements.some((l) => l.text === 'Sign up');
304+
expect(hasSignUpLink).toBe(true);
305+
}
306+
});
307+
308+
test('parses link at start followed by text', () => {
309+
const wireframe = `
310+
@scene: test
311+
312+
+----------------------------+
313+
| "Click here" for details |
314+
+----------------------------+
315+
`;
316+
317+
const result = parse(wireframe);
318+
319+
expect(result.success).toBe(true);
320+
if (result.success) {
321+
const scene = result.ast.scenes[0];
322+
323+
const findElements = (elements: any[], tags: string[]): any[] => {
324+
const found: any[] = [];
325+
for (const el of elements) {
326+
if (tags.includes(el.TAG)) {
327+
found.push(el);
328+
}
329+
if (el.children) {
330+
found.push(...findElements(el.children, tags));
331+
}
332+
}
333+
return found;
334+
};
335+
336+
const textElements = findElements(scene.elements, ['Text']);
337+
const linkElements = findElements(scene.elements, ['Link']);
338+
339+
// Should have "Click here" link
340+
const hasClickHereLink = linkElements.some((l) => l.text === 'Click here');
341+
expect(hasClickHereLink).toBe(true);
342+
343+
// Should have "for details" text
344+
const hasDetailsText = textElements.some(
345+
(t) => t.content && t.content.includes('for details')
346+
);
347+
expect(hasDetailsText).toBe(true);
348+
}
349+
});
350+
351+
test('parses text with link in middle', () => {
352+
const wireframe = `
353+
@scene: test
354+
355+
+------------------------------------+
356+
| Please "click here" to continue |
357+
+------------------------------------+
358+
`;
359+
360+
const result = parse(wireframe);
361+
362+
expect(result.success).toBe(true);
363+
if (result.success) {
364+
const scene = result.ast.scenes[0];
365+
366+
const findElements = (elements: any[], tags: string[]): any[] => {
367+
const found: any[] = [];
368+
for (const el of elements) {
369+
if (tags.includes(el.TAG)) {
370+
found.push(el);
371+
}
372+
if (el.children) {
373+
found.push(...findElements(el.children, tags));
374+
}
375+
}
376+
return found;
377+
};
378+
379+
const textElements = findElements(scene.elements, ['Text']);
380+
const linkElements = findElements(scene.elements, ['Link']);
381+
382+
// Should have "click here" link
383+
expect(linkElements.some((l) => l.text === 'click here')).toBe(true);
384+
385+
// Should have both text parts
386+
const hasPlease = textElements.some((t) => t.content && t.content.includes('Please'));
387+
const hasContinue = textElements.some(
388+
(t) => t.content && t.content.includes('to continue')
389+
);
390+
expect(hasPlease).toBe(true);
391+
expect(hasContinue).toBe(true);
392+
}
393+
});
394+
});
395+
254396
describe('device option override (Issue #11)', () => {
255397
const desktopWireframe = `
256398
@scene: test

src/parser/Semantic/SemanticParser.res

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,62 @@ let splitInlineSegments = (line: string): array<inlineSegment> => {
727727
i := i.contents + 1
728728
}
729729
}
730+
} else if char === "\"" {
731+
// Check for link pattern "..."
732+
let linkStart = i.contents
733+
let start = i.contents + 1
734+
let endPos = ref(None)
735+
let j = ref(start)
736+
// Find matching closing quote, handling escaped quotes
737+
while j.contents < len && endPos.contents === None {
738+
let currentChar = line->String.charAt(j.contents)
739+
if currentChar === "\"" {
740+
// Check if this quote is escaped (preceded by backslash)
741+
let isEscaped = j.contents > start && line->String.charAt(j.contents - 1) === "\\"
742+
if !isEscaped {
743+
endPos := Some(j.contents)
744+
}
745+
}
746+
j := j.contents + 1
747+
}
748+
749+
switch endPos.contents {
750+
| Some(end) => {
751+
let quotedContent = line->String.slice(~start, ~end)
752+
let trimmedContent = quotedContent->String.trim
753+
754+
// Check if the quoted content is not empty
755+
if trimmedContent !== "" {
756+
// Flush any accumulated text before the link
757+
let text = currentText.contents->String.trim
758+
if text !== "" {
759+
let leadingSpaces = String.length(currentText.contents) - String.length(currentText.contents->String.trimStart)
760+
segments->Array.push(TextSegment(text, currentTextStart.contents + leadingSpaces))->ignore
761+
}
762+
currentText := ""
763+
764+
// Add the link segment
765+
segments->Array.push(LinkSegment(trimmedContent, linkStart))->ignore
766+
i := end + 1
767+
currentTextStart := i.contents
768+
} else {
769+
// Empty quoted content, treat as regular text
770+
if currentText.contents === "" {
771+
currentTextStart := i.contents
772+
}
773+
currentText := currentText.contents ++ char
774+
i := i.contents + 1
775+
}
776+
}
777+
| None => {
778+
// No matching closing quote, treat as regular text
779+
if currentText.contents === "" {
780+
currentTextStart := i.contents
781+
}
782+
currentText := currentText.contents ++ char
783+
i := i.contents + 1
784+
}
785+
}
730786
} else {
731787
// Regular character
732788
if currentText.contents === "" {
@@ -965,11 +1021,14 @@ let segmentToElement = (
9651021
let actualCol = baseCol + colOffset
9661022
let position = Position.make(basePosition.row, actualCol)
9671023

1024+
// Use the same slugify logic as LinkParser for consistent ID generation
9681025
let id = text
9691026
->String.trim
9701027
->String.toLowerCase
9711028
->Js.String2.replaceByRe(%re("/\\s+/g"), "-")
9721029
->Js.String2.replaceByRe(%re("/[^a-z0-9-]/g"), "")
1030+
->Js.String2.replaceByRe(%re("/-+/g"), "-")
1031+
->Js.String2.replaceByRe(%re("/^-+|-+$/g"), "")
9731032

9741033
let align = AlignmentCalc.calculateWithStrategy(
9751034
text,
@@ -1046,10 +1105,12 @@ let parseContentLine = (
10461105
Some(registry->ParserRegistry.parse(buttonContent, position, box.bounds))
10471106
}
10481107
| Some(LinkSegment(text, colOffset)) => {
1049-
// For LinkSegment, we also need to account for leading spaces
1108+
// For single LinkSegment, pass through to ParserRegistry to use LinkParser's slugify
10501109
let actualCol = baseCol + leadingSpaces + colOffset
1051-
let adjustedPosition = Position.make(basePosition.row, actualCol)
1052-
Some(segmentToElement(LinkSegment(text, colOffset), adjustedPosition, baseCol + leadingSpaces, box.bounds))
1110+
let position = Position.make(row, actualCol)
1111+
// Reconstruct the quoted text format for the parser
1112+
let linkContent = "\"" ++ text ++ "\""
1113+
Some(registry->ParserRegistry.parse(linkContent, position, box.bounds))
10531114
}
10541115
| Some(TextSegment(_, _)) | None => {
10551116
// For single text segment, use original position calculation

0 commit comments

Comments
 (0)