From fbbf9c085471505cef0344cab053a91ebdfcf3cd Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Thu, 24 Sep 2026 19:06:21 +0200 Subject: [PATCH 01/12] Render every FAQ through BlogFaq faq-answer.mjs now parses answers into nodes instead of HTML strings, and InlineMarkdown renders them, so no answer needs v-html. Pricing and the OPC UA page use BlogFaq instead of their own FAQ markup, with answers moved from raw HTML to the markdown subset. --- .claude/CLAUDE.md | 4 + nuxt/components/BlogFaq.vue | 16 +-- nuxt/components/InlineMarkdown.vue | 24 ++++ .../components/industry-page/Applications.vue | 5 +- nuxt/content/faq/pricing.yml | 10 +- nuxt/lib/faq-answer.mjs | 102 ++++++++-------- nuxt/lib/faq-answer.test.mjs | 115 +++++++++--------- nuxt/pages/integrations/opcua.vue | 36 +----- nuxt/pages/pricing/index.vue | 18 +-- .../use-cases/remote-device-management.vue | 12 +- 10 files changed, 155 insertions(+), 187 deletions(-) create mode 100644 nuxt/components/InlineMarkdown.vue diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3769d27852..4a971667de 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -395,6 +395,10 @@ These are the destination events, not `blog-cta`: inline links also appear on ch Keep that art out of `nuxt/assets/icons/`. That directory is the `ff` Nuxt Icon collection (``), which parses every file in it at build time and fails the build on a bare `` fragment, which several of the nav icons are. +## FAQs + +Every FAQ on the site renders through `nuxt/components/BlogFaq.vue`, including `/pricing/` (answers in `nuxt/content/faq/pricing.yml`). Answers are plain text with a small markdown subset (`[label](url)`, `**bold**`, `*italic*`, blank-line paragraphs, `-`/`1.` lists), never HTML. `nuxt/lib/faq-answer.mjs` parses them into nodes, not HTML strings, and `nuxt/components/InlineMarkdown.vue` renders those nodes. Vue escapes the text, so literal placeholders like `` stay literal. The same component renders one-line copy with that subset, e.g. ``. Don't render these strings with `v-html`. + ## Naming conventions - All slugs: **kebab-case** diff --git a/nuxt/components/BlogFaq.vue b/nuxt/components/BlogFaq.vue index 1011af4c5d..e8a3a4379d 100644 --- a/nuxt/components/BlogFaq.vue +++ b/nuxt/components/BlogFaq.vue @@ -1,8 +1,5 @@ + + diff --git a/nuxt/components/industry-page/Applications.vue b/nuxt/components/industry-page/Applications.vue index 418dab8494..b76d45dee1 100644 --- a/nuxt/components/industry-page/Applications.vue +++ b/nuxt/components/industry-page/Applications.vue @@ -1,7 +1,6 @@ '), ['<script>alert(1)</script>']) - assert.deepEqual(renderFaqAnswer('a & b'), ['a & b']) +const text = value => ({ type: 'text', value }) +const p = (...children) => ({ type: 'p', children }) + +test('markup stays literal text, for Vue to escape', () => { + assert.deepEqual(parseFaqAnswer('use here'), [p(text('use here'))]) + assert.deepEqual(parseFaqAnswer(''), [p(text(''))]) }) -test('renders site-absolute and http links', () => { - assert.deepEqual( - renderFaqAnswer('see the [pricing page](/pricing)'), - ['see the pricing page'], - ) - assert.deepEqual( - renderFaqAnswer('[device-agent](https://github.com/FlowFuse/device-agent)'), - ['device-agent'], - ) +test('parses site-absolute and http links', () => { + assert.deepEqual(parseInline('see the [pricing page](/pricing)'), [ + text('see the '), + { type: 'link', href: '/pricing', children: [text('pricing page')] }, + ]) + assert.deepEqual(parseInline('[device-agent](https://github.com/FlowFuse/device-agent)'), [ + { type: 'link', href: 'https://github.com/FlowFuse/device-agent', children: [text('device-agent')] }, + ]) }) test('leaves a non-http, non-absolute link target as text', () => { - assert.deepEqual(renderFaqAnswer('[x](javascript:alert(1))'), ['[x](javascript:alert(1))']) + assert.deepEqual(parseInline('[x](javascript:alert(1))'), [text('[x](javascript:alert(1))')]) +}) + +test('parses bold and italic', () => { + assert.deepEqual(parseInline('save you *a lot* of clicking'), [ + text('save you '), { type: 'em', children: [text('a lot')] }, text(' of clicking'), + ]) + assert.deepEqual(parseInline('**Fleet Mode** deploys'), [ + { type: 'strong', children: [text('Fleet Mode')] }, text(' deploys'), + ]) }) -test('renders bold and italic', () => { - assert.deepEqual(renderFaqAnswer('save you *a lot* of clicking'), ['save you a lot of clicking']) - assert.deepEqual(renderFaqAnswer('**Fleet Mode** deploys'), ['Fleet Mode deploys']) +test('nests links and emphasis either way round', () => { + assert.deepEqual(parseInline('**see [docs](/docs/)**'), [ + { type: 'strong', children: [text('see '), { type: 'link', href: '/docs/', children: [text('docs')] }] }, + ]) + assert.deepEqual(parseInline('[**docs**](/docs/)'), [ + { type: 'link', href: '/docs/', children: [{ type: 'strong', children: [text('docs')] }] }, + ]) +}) + +test('an asterisk inside a URL is not emphasis', () => { + assert.deepEqual(parseInline('[x](/a*b*c)'), [{ type: 'link', href: '/a*b*c', children: [text('x')] }]) }) test('an unclosed marker stays text rather than swallowing the answer', () => { - assert.deepEqual(renderFaqAnswer('2 * 3 is six'), ['2 * 3 is six']) + assert.deepEqual(parseInline('2 * 3 is six'), [text('2 * 3 is six')]) }) test('a blank line starts a new paragraph', () => { - assert.deepEqual( - renderFaqAnswer('First point.\n\nSecond point.'), - ['First point.', 'Second point.'], - ) + assert.deepEqual(parseFaqAnswer('First point.\n\nSecond point.'), [p(text('First point.')), p(text('Second point.'))]) }) -test('empty input renders no paragraphs', () => { - assert.deepEqual(renderFaqAnswer(''), []) - assert.deepEqual(renderFaqAnswer(undefined), []) +test('empty input parses to no blocks', () => { + assert.deepEqual(parseFaqAnswer(''), []) + assert.deepEqual(parseFaqAnswer(undefined), []) }) -test('renders an unordered list when every line is a bullet', () => { - assert.deepEqual( - renderFaqAnswer('- first\n- second'), - ['
  • first
  • second
'], - ) +test('parses an unordered list when every line is a bullet', () => { + assert.deepEqual(parseFaqAnswer('- first\n- second'), [{ type: 'ul', items: [[text('first')], [text('second')]] }]) }) -test('renders an ordered list, and links inside items', () => { - assert.deepEqual( - renderFaqAnswer('1. go to [pricing](/pricing)\n2. pick a plan'), - ['
  1. go to pricing
  2. pick a plan
'], - ) +test('parses an ordered list, and links inside items', () => { + assert.deepEqual(parseFaqAnswer('1. go to [pricing](/pricing)\n2. pick a plan'), [{ + type: 'ol', + items: [ + [text('go to '), { type: 'link', href: '/pricing', children: [text('pricing')] }], + [text('pick a plan')], + ], + }]) }) test('a paragraph that only partly looks like a list stays a paragraph', () => { - assert.deepEqual( - renderFaqAnswer('Here is why:\n- because'), - ['Here is why: - because'], - ) + assert.deepEqual(parseFaqAnswer('Here is why:\n- because'), [p(text('Here is why: - because'))]) }) test('joins the wrapped lines of a paragraph with spaces', () => { - assert.deepEqual( - renderFaqAnswer('one line\nwrapped onto another'), - ['one line wrapped onto another'], - ) -}) - -test('a list and a paragraph in one answer render as separate blocks', () => { - assert.deepEqual( - renderFaqAnswer('Uses include:\n\n- monitoring\n- alerting\n\nAnd more.'), - ['Uses include:', '
  • monitoring
  • alerting
', 'And more.'], - ) + assert.deepEqual(parseFaqAnswer('one line\nwrapped onto another'), [p(text('one line wrapped onto another'))]) }) -test('isListBlock marks only list blocks', () => { - assert.equal(isListBlock('
  • x
'), true) - assert.equal(isListBlock('
  1. x
'), true) - assert.equal(isListBlock('plain text'), false) - assert.equal(isListBlock('bold text'), false) +test('a list and a paragraph in one answer parse as separate blocks', () => { + assert.deepEqual(parseFaqAnswer('Uses include:\n\n- monitoring\n- alerting\n\nAnd more.'), [ + p(text('Uses include:')), + { type: 'ul', items: [[text('monitoring')], [text('alerting')]] }, + p(text('And more.')), + ]) }) diff --git a/nuxt/pages/integrations/opcua.vue b/nuxt/pages/integrations/opcua.vue index ac2634fef1..52964af560 100644 --- a/nuxt/pages/integrations/opcua.vue +++ b/nuxt/pages/integrations/opcua.vue @@ -271,8 +271,6 @@ const webinars = [ { title: 'Getting Started with OPC-UA and Node-RED', image: '/images/webinars/getting-started-with-opc-ua-and-node-red-webinar-august.jpg', alt: 'Getting started with OPC-UA and Node-RED webinar', url: '/webinars/2023/getting-started-opcua-node-red/' }, ] -// Answers include internal links, rendered with v-html below - BlogFaq.vue interpolates -// plain text only, so it can't be reused here without stripping those links. const faq = [ { question: 'Is there a free OPC UA client?', @@ -280,7 +278,7 @@ const faq = [ }, { question: 'Is OPC UA secure?', - answer: 'OPC UA builds security into the specification rather than bolting it on afterward: application authentication via X.509 certificates, user authentication, and message-level SignAndEncrypt for integrity and confidentiality. The problem in practice isn\'t the spec. Trust lists get left in "accept all" mode, self-signed certificates never get rotated, and Security Policy gets set to None for convenience during commissioning and never revisited. Our two-part guide covers both sides: how attackers actually exploit these gaps and how to build a defensible architecture.', + answer: 'OPC UA builds security into the specification rather than bolting it on afterward: application authentication via X.509 certificates, user authentication, and message-level SignAndEncrypt for integrity and confidentiality. The problem in practice isn\'t the spec. Trust lists get left in "accept all" mode, self-signed certificates never get rotated, and Security Policy gets set to None for convenience during commissioning and never revisited. Our two-part guide covers both sides: [how attackers actually exploit these gaps](/blog/2026/05/opc-ua-security-attack-vectors/) and [how to build a defensible architecture](/blog/2026/06/opc-ua-security-best-practices/).', }, { question: 'Can FlowFuse act as both an OPC UA client and an OPC UA server?', @@ -288,18 +286,13 @@ const faq = [ }, { question: 'Do I need Kepware or another OPC UA gateway product?', - answer: 'Not necessarily. Kepware and similar products became the default when building an OPC UA server or client from scratch was hard, but per-tag pricing scales badly and recent ownership changes have made long-term cost and support less predictable. FlowFuse can connect to an existing Kepware server as a client on any deployment (see our Kepware connection guide), or replace it outright. See Beyond Kepware: Why Modern Industrial Connectivity Demands a Second Look.', + answer: 'Not necessarily. Kepware and similar products became the default when building an OPC UA server or client from scratch was hard, but per-tag pricing scales badly and recent ownership changes have made long-term cost and support less predictable. FlowFuse can connect to an existing Kepware server as a client on any deployment (see our [Kepware connection guide](/blog/2024/02/connect-node-red-to-kepware-opc/)), or replace it outright. See [Beyond Kepware: Why Modern Industrial Connectivity Demands a Second Look](/blog/2026/01/kepware-opcua-better-alternative/).', }, { question: 'Does FlowFuse work on-premises or in the cloud for OPC UA?', answer: 'Both. Run FlowFuse on FlowFuse Cloud, self-managed on your own infrastructure, or on the FlowFuse Device Agent at the edge, right next to the OPC UA servers you\'re connecting to. Many teams run the OPC UA client on an industrial PC inside the plant network, then forward selected data outward over MQTT, so nothing inside the plant has to accept inbound connections from outside it.', }, ] - -const openFaqIndex = ref(null) -function toggleFaq (i: number) { - openFaqIndex.value = openFaqIndex.value === i ? null : i -} diff --git a/nuxt/components/content/ProseA.vue b/nuxt/components/content/ProseA.vue index 5acd63c5ea..f90d937588 100644 --- a/nuxt/components/content/ProseA.vue +++ b/nuxt/components/content/ProseA.vue @@ -1,5 +1,5 @@