Skip to content

Commit 35897ac

Browse files
committed
Enabling links in welcome question / welcome description
1 parent 355ac25 commit 35897ac

6 files changed

Lines changed: 145 additions & 3 deletions

File tree

apps/portal/.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,14 @@ PUBLIC_LOGO_WIDTH="200px"
7878

7979
PUBLIC_WELCOME_TITLE=Your Custom Welcome Title!
8080
PUBLIC_HELP_QUESTION=What would you like to know?
81-
PUBLIC_WELCOME_DESCRIPTION=Your custom description text that will appear in the welcome splash screen.
81+
# Supports markdown-style links: [link text](https://example.com) — http(s), mailto: and
82+
# site-relative (/path) URLs only. All other markup is shown as literal text.
83+
PUBLIC_WELCOME_DESCRIPTION=Your custom description text that will appear in the welcome splash screen. Read [the docs](https://github.com/OpenBankProject) for more.
84+
85+
# One-time welcome bubble shown on a visitor's first visit (dismissal is remembered
86+
# in a cookie for a year). Not shown if unset or empty.
87+
# Supports the same markdown-style link syntax as PUBLIC_WELCOME_DESCRIPTION.
88+
# PUBLIC_WELCOME_MESSAGE=Welcome to our sandbox! See [getting started](/developers/getting-started) to begin.
8289

8390
# Suggested Questions for the Opey Chat
8491
PUBLIC_SUGGESTED_QUESTIONS='[{"questionString":"How can I get started?","pillTitle":"Getting Started","icon":"Rocket"},{"questionString":"How do I authenticate?","pillTitle":"Authentication","icon":"UserLock"},{"questionString":"How do I use consents?","pillTitle":"Consents","icon":"CheckCheck"},{"questionString":"What SDKs are available?","pillTitle":"SDKs","icon":"Layers"}]'

apps/portal/src/lib/components/WelcomeBubble.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { onMount } from 'svelte';
33
import { X } from '@lucide/svelte';
44
import { env } from '$env/dynamic/public';
5+
import { renderTextWithLinks } from '@obp/shared/markdown';
56
67
let showBubble = $state(false);
78
let isClosing = $state(false);
@@ -11,6 +12,12 @@
1112
1213
// Get welcome message from environment variable
1314
const welcomeMessage = env.PUBLIC_WELCOME_MESSAGE || '';
15+
// Supports markdown-style links, e.g. "See [the docs](https://example.com)";
16+
// everything else is escaped, so the result is safe for {@html}
17+
const welcomeMessageHtml = renderTextWithLinks(welcomeMessage, {
18+
// primary-* is near-black in the OBP theme, so use tertiary for a visible hover
19+
linkClass: 'underline hover:text-tertiary-600 dark:hover:text-tertiary-400'
20+
});
1421
1522
onMount(() => {
1623
// Don't show bubble if welcome message is not set or empty
@@ -85,7 +92,7 @@
8592

8693
<!-- Welcome message -->
8794
<div class="pr-6 text-sm leading-relaxed text-surface-900 dark:text-surface-100">
88-
{welcomeMessage}
95+
{@html welcomeMessageHtml}
8996
</div>
9097

9198
<!-- Decorative accent -->

apps/portal/src/routes/+page.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { OpeyChat, CurrentBankPicker, AccountScopePicker } from '@obp/shared/components';
3+
import { renderTextWithLinks } from '@obp/shared/markdown';
34
import type { OpeyChatOptions, SuggestedQuestion } from '@obp/shared/components';
45
import { CheckCheck, Layers, Rocket, UserLock, HelpCircle } from '@lucide/svelte';
56
import { env } from '$env/dynamic/public';
@@ -17,6 +18,12 @@
1718
const welcomeTitle = env.PUBLIC_WELCOME_TITLE || 'Welcome!';
1819
const helpQuestion = env.PUBLIC_HELP_QUESTION || 'How can I help?';
1920
const welcomeDescription = env.PUBLIC_WELCOME_DESCRIPTION || 'Welcome to the Open Bank Project sandbox — where developers, Fintechs, and banks can build and test innovative open banking ++ solutions.';
21+
// Supports markdown-style links, e.g. "See [the docs](https://example.com)";
22+
// everything else is escaped, so the result is safe for {@html}
23+
const welcomeDescriptionHtml = renderTextWithLinks(welcomeDescription, {
24+
// primary-* is near-black in the OBP theme, so use tertiary for a visible hover
25+
linkClass: 'underline hover:text-tertiary-600 dark:hover:text-tertiary-400'
26+
});
2027
2128
// Icon mapping for configurable questions
2229
const iconMap: Record<string, typeof Rocket> = {
@@ -75,7 +82,7 @@
7582
<h1 class="h3 text-surface-700-300 mb-2">{welcomeTitle}</h1>
7683
<h1 class="h3 mb-4">{helpQuestion}</h1>
7784
<p class="text-surface-700-300 mb-7 text-sm">
78-
{welcomeDescription}
85+
{@html welcomeDescriptionHtml}
7986
</p>
8087
</div>
8188
{/snippet}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
export { renderMarkdown } from './helper-funcs.js';
2+
export { renderTextWithLinks } from './links.js';
3+
export type { RenderTextWithLinksOptions } from './links.js';
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { renderTextWithLinks } from './links';
3+
4+
describe('renderTextWithLinks', () => {
5+
it('returns plain text unchanged (escaped)', () => {
6+
expect(renderTextWithLinks('Hello world')).toBe('Hello world');
7+
});
8+
9+
it('escapes HTML in plain text', () => {
10+
expect(renderTextWithLinks('<b>bold</b> & "quotes"')).toBe(
11+
'&lt;b&gt;bold&lt;/b&gt; &amp; &quot;quotes&quot;'
12+
);
13+
});
14+
15+
it('converts an https link and opens it in a new tab', () => {
16+
expect(renderTextWithLinks('See [the docs](https://example.com/docs) for more')).toBe(
17+
'See <a href="https://example.com/docs" target="_blank" rel="noopener noreferrer">the docs</a> for more'
18+
);
19+
});
20+
21+
it('converts a site-relative link without target=_blank', () => {
22+
expect(renderTextWithLinks('Go to [registration](/register)')).toBe(
23+
'Go to <a href="/register">registration</a>'
24+
);
25+
});
26+
27+
it('converts a mailto link without target=_blank', () => {
28+
expect(renderTextWithLinks('[Contact us](mailto:hello@example.com)')).toBe(
29+
'<a href="mailto:hello@example.com">Contact us</a>'
30+
);
31+
});
32+
33+
it('handles multiple links in one string', () => {
34+
expect(renderTextWithLinks('[a](/x) and [b](/y)')).toBe(
35+
'<a href="/x">a</a> and <a href="/y">b</a>'
36+
);
37+
});
38+
39+
it('applies the linkClass option', () => {
40+
expect(renderTextWithLinks('[docs](/docs)', { linkClass: 'underline' })).toBe(
41+
'<a href="/docs" class="underline">docs</a>'
42+
);
43+
});
44+
45+
it('leaves javascript: URLs as escaped literal text', () => {
46+
expect(renderTextWithLinks('[click](javascript:alert(1))')).toBe(
47+
'[click](javascript:alert(1))'
48+
);
49+
});
50+
51+
it('leaves protocol-relative URLs as literal text', () => {
52+
expect(renderTextWithLinks('[click](//evil.example)')).toBe('[click](//evil.example)');
53+
});
54+
55+
it('escapes HTML inside the link label and URL', () => {
56+
expect(renderTextWithLinks('["quoted" <label>](/path?a=1&b=2)')).toBe(
57+
'<a href="/path?a=1&amp;b=2">&quot;quoted&quot; &lt;label&gt;</a>'
58+
);
59+
});
60+
61+
it('does not treat bare brackets as a link', () => {
62+
expect(renderTextWithLinks('array[0] and (note)')).toBe('array[0] and (note)');
63+
});
64+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Renders plain text that may contain markdown-style links — `[label](url)` —
3+
* into an HTML string. Everything except the generated <a> tags is escaped,
4+
* so the result is safe to use with {@html}. Only http(s), mailto: and
5+
* site-relative (/path, #anchor) URLs become links; anything else
6+
* (e.g. javascript:) is rendered as escaped literal text.
7+
*
8+
* Intended for operator-configured one-liners (env vars like
9+
* PUBLIC_WELCOME_DESCRIPTION) where full markdown would be overkill.
10+
*/
11+
12+
const LINK_PATTERN = /\[([^\]]+)\]\(([^()\s]+)\)/g;
13+
14+
function escapeHtml(text: string): string {
15+
return text
16+
.replace(/&/g, '&amp;')
17+
.replace(/</g, '&lt;')
18+
.replace(/>/g, '&gt;')
19+
.replace(/"/g, '&quot;');
20+
}
21+
22+
function isSafeHref(url: string): boolean {
23+
if (url.startsWith('//')) {
24+
// Protocol-relative URLs inherit the page scheme but hide the host; disallow
25+
return false;
26+
}
27+
return /^(https?:\/\/|mailto:|\/|#)/i.test(url);
28+
}
29+
30+
export interface RenderTextWithLinksOptions {
31+
/** CSS class(es) applied to each generated <a> tag */
32+
linkClass?: string;
33+
}
34+
35+
export function renderTextWithLinks(
36+
text: string,
37+
options: RenderTextWithLinksOptions = {}
38+
): string {
39+
const classAttr = options.linkClass ? ` class="${escapeHtml(options.linkClass)}"` : '';
40+
let html = '';
41+
let lastIndex = 0;
42+
for (const match of text.matchAll(LINK_PATTERN)) {
43+
const [full, label, url] = match;
44+
html += escapeHtml(text.slice(lastIndex, match.index));
45+
if (isSafeHref(url)) {
46+
const opensNewTab = /^https?:\/\//i.test(url);
47+
const targetAttr = opensNewTab ? ' target="_blank" rel="noopener noreferrer"' : '';
48+
html += `<a href="${escapeHtml(url)}"${classAttr}${targetAttr}>${escapeHtml(label)}</a>`;
49+
} else {
50+
html += escapeHtml(full);
51+
}
52+
lastIndex = match.index + full.length;
53+
}
54+
return html + escapeHtml(text.slice(lastIndex));
55+
}

0 commit comments

Comments
 (0)