diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 3769d27852..0cfa4bd504 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -360,7 +360,7 @@ A link inside a sentence keeps free-form text (it has to fit the prose), so it i Markdown needs no markup for this: `nuxt/components/content/ProseA.vue` renders every markdown link, and when `ctaDestinationKey()` (in `cta-destinations.ts`) matches the href, it renders `` instead of Nuxt UI's `ProseA`, passing the link's query string through as `query`. `prose` keeps Nuxt UI's prose link styling. Matching ignores the trailing slash and the query string, `https://flowfuse.com/...` counts the same as a relative path, and `http://` the same as `https://`. A hash points at a section of the page, so it doesn't match. So `[FlowFuse Cloud](site:appURL)`, `[try it for free](cta:signUp)` and a hand-typed `https://app.flowfuse.com/account/create?code=...` are all tracked. `cta:` (resolved by `nuxt/utils/remark-site-links.ts`) is still the preferred way to write the href, and takes a query string too: `[contact us](cta:contactUs?subject=Certified%20Nodes)`. -These are the destination events, not `blog-cta`: inline links also appear on changelog, webinar, handbook and docs pages, where a `Blog: ` reference would be wrong, and PostHog already records the page URL. Hrefs from `CUSTOM_CTA_DESTINATIONS` aren't matched. FAQ answers (`nuxt/lib/faq-answer.mjs`, rendered with `v-html`) can't hold a component, so their links aren't tracked. +These are the destination events, not `blog-cta`: inline links also appear on changelog, webinar, handbook and docs pages, where a `Blog: <title>` reference would be wrong, and PostHog already records the page URL. Hrefs from `CUSTOM_CTA_DESTINATIONS` aren't matched. FAQs follow the same rule (see **FAQs**). `nuxt/lib/cta-destinations.test.mjs` fails `npm test` if a `.vue` file hard-codes an `href`/`to` to one of the five destinations, so a link can't skip `CtaLink` or the matching button. It only sees literal values; a dynamic `:href` still relies on the caller. @@ -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 (`<UIcon name="i-ff-pin" />`), which parses every file in it at build time and fails the build on a bare `<path>` fragment, which several of the nav icons are. +## FAQs + +Every FAQ on the site renders through `nuxt/components/Faq.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` splits an answer into paragraphs and lists, `nuxt/lib/inline-markdown.mjs` parses the text inside them into nodes (not HTML strings), and `nuxt/components/InlineMarkdown.vue` renders those nodes. Vue escapes the text, so literal placeholders like `<ip>` stay literal. A link to a CTA destination renders through `CtaLink` (see **Inline links**), tracked with the `position` passed to `InlineMarkdown` (`faq` in `Faq`). Page copy outside FAQs that uses the inline part of the subset renders through the same component, e.g. `<InlineMarkdown :text="item.description" position="applications" />`. Don't render these strings with `v-html`. FAQ structured data (`defineQuestion`) takes `faqAnswerText(answer)` from the same file, the answer as plain text, not the raw markdown. + ## Naming conventions - All slugs: **kebab-case** diff --git a/nuxt/components/BlogFaq.vue b/nuxt/components/Faq.vue similarity index 70% rename from nuxt/components/BlogFaq.vue rename to nuxt/components/Faq.vue index 1011af4c5d..0b5e48b43f 100644 --- a/nuxt/components/BlogFaq.vue +++ b/nuxt/components/Faq.vue @@ -1,10 +1,7 @@ <script setup lang="ts"> -// The answer renderer moved to nuxt/lib/faq-answer.mjs so it can be unit tested and so -// the escaping rules live in one place; it also gained *italic*, **bold** and blank-line -// paragraphs, which the 11ty pages expressed with raw <i> and <p> tags under `| safe`. -import { isListBlock, renderFaqAnswer } from '../lib/faq-answer.mjs' +import { parseFaqAnswer } from '../lib/faq-answer.mjs' -withDefaults(defineProps<{ +const props = withDefaults(defineProps<{ faq: Array<{ question: string, answer: string }> /** * How much room the block takes above the first question. @@ -19,6 +16,8 @@ withDefaults(defineProps<{ variant: 'post', }) +const answers = computed(() => props.faq.map(item => parseFaqAnswer(item.answer))) + const openIndex = ref<number | null>(null) function toggle(i: number) { openIndex.value = openIndex.value === i ? null : i @@ -46,12 +45,11 @@ function toggle(i: number) { </button> </h3> <div v-show="openIndex === i" class="px-6 mt-6"> - <template v-for="(block, b) in renderFaqAnswer(item.answer)" :key="b"> - <!-- A <ul>/<ol> cannot sit inside a <p>, so a list block renders bare. - eslint-disable-next-line vue/no-v-html --> - <div v-if="isListBlock(block)" class="ff-faq-list" v-html="block" /> - <!-- eslint-disable-next-line vue/no-v-html --> - <p v-else v-html="block" /> + <template v-for="(block, b) in answers[i]" :key="b"> + <p v-if="block.type === 'p'"><InlineMarkdown :nodes="block.children" position="faq" /></p> + <component :is="block.type" v-else> + <li v-for="(li, l) in block.items" :key="l"><InlineMarkdown :nodes="li" position="faq" /></li> + </component> </template> </div> </div> diff --git a/nuxt/components/InlineMarkdown.vue b/nuxt/components/InlineMarkdown.vue new file mode 100644 index 0000000000..13b742e508 --- /dev/null +++ b/nuxt/components/InlineMarkdown.vue @@ -0,0 +1,28 @@ +<script setup lang="ts"> +import { parseInline } from '../lib/inline-markdown.mjs' +import { ctaLink } from '../lib/cta-destinations' + +type InlineNode = + | { type: 'text', value: string } + | { type: 'strong' | 'em', children: InlineNode[] } + | { type: 'link', href: string, children: InlineNode[] } + +const props = defineProps<{ + text?: string + nodes?: InlineNode[] + position: string +}>() + +const list = computed<InlineNode[]>(() => props.nodes ?? parseInline(props.text ?? '')) +const ctas = computed(() => list.value.map(node => node.type === 'link' ? ctaLink(node.href) : undefined)) +</script> + +<template> + <template v-for="(node, i) in list" :key="i"> + <template v-if="node.type === 'text'">{{ node.value }}</template> + <strong v-else-if="node.type === 'strong'"><InlineMarkdown :nodes="node.children" :position="position" /></strong> + <em v-else-if="node.type === 'em'"><InlineMarkdown :nodes="node.children" :position="position" /></em> + <CtaLink v-else-if="ctas[i]" :destination="ctas[i]!.destination" :query="ctas[i]!.query" :position="position"><InlineMarkdown :nodes="node.children" :position="position" /></CtaLink> + <a v-else :href="node.href"><InlineMarkdown :nodes="node.children" :position="position" /></a> + </template> +</template> 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 @@ <template> - <CtaLink v-if="destination" :destination="destination" :query="query" :target="target" position="inline-link" prose> + <CtaLink v-if="cta" :destination="cta.destination" :query="cta.query" :target="target" position="inline-link" prose> <slot /> </CtaLink> <UiProseA v-else :href="href" :target="target"> @@ -9,13 +9,12 @@ <script setup lang="ts"> import UiProseA from '@nuxt/ui/components/prose/A.vue' -import { ctaDestinationKey, ctaQuery } from '../../lib/cta-destinations' +import { ctaLink } from '../../lib/cta-destinations' const props = defineProps<{ href?: string target?: string }>() -const destination = computed(() => ctaDestinationKey(props.href)) -const query = computed(() => destination.value ? ctaQuery(props.href!) : undefined) +const cta = computed(() => ctaLink(props.href)) </script> diff --git a/nuxt/components/industry-page/Applications.vue b/nuxt/components/industry-page/Applications.vue index 418dab8494..60309bc09e 100644 --- a/nuxt/components/industry-page/Applications.vue +++ b/nuxt/components/industry-page/Applications.vue @@ -1,7 +1,6 @@ <script setup lang="ts"> // The zigzag "Applications for X Manufacturing" band from automotive.vue. -// Supports the same `[label](url)` inline-link subset as BlogFaq's answers. -import { inline } from '../../lib/faq-answer.mjs' +// Supports the same `[label](url)` inline-link subset as Faq's answers. type Item = { title: string @@ -61,16 +60,14 @@ const VARIANTS: Record<string, { gradient: string, border: string, mobileGradien </div> </div> <!-- A blank line in the source splits the description into separate paragraphs, - same convention as BlogFaq's answers (faq-answer.mjs's own blank-line rule) - + same convention as Faq's answers (faq-answer.mjs's own blank-line rule) - for a trailing aside that shouldn't run into the main copy. --> - <!-- eslint-disable-next-line vue/no-v-html --> <p v-for="(paragraph, i) in item.description.split(/\n\n+/)" :key="i" class="text-gray-500 m-0" :class="{ 'mt-3': i > 0 }" - v-html="inline(paragraph)" - /> + ><InlineMarkdown :text="paragraph" position="applications" /></p> <a :href="item.linkHref" class="flex items-center gap-1.5 text-blue-600 hover:underline max-md:justify-center"> {{ item.linkText }} <UIcon name="i-heroicons-arrow-long-right" class="w-6 h-6 shrink-0" /> diff --git a/nuxt/components/industry-page/Template.vue b/nuxt/components/industry-page/Template.vue index efd775da98..e094f41ad9 100644 --- a/nuxt/components/industry-page/Template.vue +++ b/nuxt/components/industry-page/Template.vue @@ -2,6 +2,8 @@ // The shared page body for every industry page built on the automotive.vue template. // A thin pages/industries/<slug>.vue queries its own `industries` collection entry and // renders it through this one component, so the band markup lives in exactly one place. +import { faqAnswerText } from '../../lib/faq-answer.mjs' + const props = defineProps<{ page: { slug: string @@ -44,7 +46,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...props.page.faqs.map(item => defineQuestion(item)), + ...props.page.faqs.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -79,7 +81,7 @@ useSchemaOrg([ <div class="w-full px-6 pt-20 pb-10"> <div class="max-w-screen-lg mx-auto"> <h2 class="mb-1 text-center md:text-left">Frequently Asked <span class="text-indigo-600">Questions</span></h2> - <BlogFaq :faq="page.faqs" /> + <Faq :faq="page.faqs" /> </div> </div> diff --git a/nuxt/content/faq/pricing.yml b/nuxt/content/faq/pricing.yml index 828be7cdae..660b1c6ced 100644 --- a/nuxt/content/faq/pricing.yml +++ b/nuxt/content/faq/pricing.yml @@ -2,16 +2,16 @@ page: pricing title: 'Frequently Asked <span class="text-indigo-600">Questions</span>' items: - question: "What's the difference between Edge and Hub?" - answer: "Edge is a product designed specifically for OT teams and the unique challenges they manage. With Edge, you can connect PLCs, machines, and systems, applying a standardised methodology for deploying automation at scale. This tier comes with everything in the FlowFuse core platform, but also includes support for Device Fleet Updates and Device Group Management. Additionally, this tier includes the OT Certified Nodes collection and deployable OT Application Blueprints.<br><br>Hub is a product better suited for IT teams moving data between their systems and orchestrating multi-plant installations. For this reason, it includes everything in the FlowFuse core platform, as well as some particularly useful features for this use case. It includes Git Integration, High Availability, BAA for HIPAA, and Protected Instances. It also includes the IT Certified Nodes collection and deployable IT Application Blueprints.<br><br>For customers requiring something between these two featuresets, please <a href='/contact-us'>Contact Us</a> so we can help build a tailored solution for your environment!" + answer: "Edge is a product designed specifically for OT teams and the unique challenges they manage. With Edge, you can connect PLCs, machines, and systems, applying a standardised methodology for deploying automation at scale. This tier comes with everything in the FlowFuse core platform, but also includes support for Device Fleet Updates and Device Group Management. Additionally, this tier includes the OT Certified Nodes collection and deployable OT Application Blueprints.\n\nHub is a product better suited for IT teams moving data between their systems and orchestrating multi-plant installations. For this reason, it includes everything in the FlowFuse core platform, as well as some particularly useful features for this use case. It includes Git Integration, High Availability, BAA for HIPAA, and Protected Instances. It also includes the IT Certified Nodes collection and deployable IT Application Blueprints.\n\nFor customers requiring something between these two featuresets, please [Contact Us](/contact-us/) so we can help build a tailored solution for your environment!" - question: "Can I self-host FlowFuse, or is it cloud only?" - answer: "Both are available. Edge, Hub, and Fleet can run on FlowFuse Cloud, fully managed by us, or self-hosted on your own infrastructure - on-premise, in your private cloud, or in a fully air-gapped network. Self-hosted installs are supported on <a href='/docs/install/docker/'>Docker</a> and <a href='/docs/install/kubernetes/'>Kubernetes</a>; see the <a href='/docs/install/introduction/'>installation documentation</a> to get started, or <a href='/contact-us/'>Contact Us</a> to discuss a self-hosted deployment for your environment." + answer: "Both are available. Edge, Hub, and Fleet can run on FlowFuse Cloud, fully managed by us, or self-hosted on your own infrastructure - on-premise, in your private cloud, or in a fully air-gapped network. Self-hosted installs are supported on [Docker](/docs/install/docker/) and [Kubernetes](/docs/install/kubernetes/); see the [installation documentation](/docs/install/introduction/) to get started, or [Contact Us](/contact-us/) to discuss a self-hosted deployment for your environment." - question: "How does customer support work for FlowFuse?" - answer: "FlowFuse provides active support for Certified Nodes and the FlowFuse platform per the <a href='/handbook/sales/customer-success/#sla'>SLA</a>. Customers with these features can get support by <a href='/support'>filing a ticket</a> or via help information in the <a href='/support'>FlowFuse Support Portal</a>. For general issues related to Node-RED or third party modules, raise issues in the <a href='https://community.flowfuse.com/'>community forum</a>." + answer: "FlowFuse provides active support for Certified Nodes and the FlowFuse platform per the [SLA](/handbook/sales/customer-success/#sla). Customers with these features can get support by [filing a ticket](/support/) or via help information in the [FlowFuse Support Portal](/support/). For general issues related to Node-RED or third party modules, raise issues in the [community forum](https://community.flowfuse.com/)." - question: "How does FlowFuse secure my data?" - answer: "FlowFuse employs a layered approach to securing customer data. For a fuller understanding of the mechanisms behind this approach, please read our <a href='/platform/security/'>security statement</a> as well as our <a href='/docs/contribute/architecture/'>architecture documentation</a>.<br><br>FlowFuse secures user access through username/password, SAML-based SSO, or LDAP, with support for multi-factor authentication and automatic session expiry after 14 days. Access is governed by role-based controls within Teams - Owner, Member, Viewer, and Dashboard roles - while platform-wide admin access is tightly restricted (on FlowFuse Cloud, limited to vetted employees on a need-to-have basis). All administrative and team-level actions are captured in audit logs. On FlowFuse Cloud, data is encrypted at rest with AES-256 (PostgreSQL and AWS EFS), stored in the EU (Ireland), and protected in transit with TLS 1.2 or higher terminated at the edge for both HTTP and MQTT traffic.<br><br>Organizationally, FlowFuse holds SOC 2 Type 1 and Type 2 certifications (audited by Advantage Partners) and maintains formal information security, acceptable use, and data management policies alongside regular security awareness training. The platform is also built to be GxP validation-ready: features like role-based access control, audit logging, instance versioning and snapshots, infrastructure-as-code, and SSO/LDAP integration align with the traceability and change-control expectations of regulated environments such as FDA 21 CFR Part 11 and EU GMP Annex 11, enabling customers to perform their own validation and supplier qualification." + answer: "FlowFuse employs a layered approach to securing customer data. For a fuller understanding of the mechanisms behind this approach, please read our [security statement](/platform/security/) as well as our [architecture documentation](/docs/contribute/architecture/).\n\nFlowFuse secures user access through username/password, SAML-based SSO, or LDAP, with support for multi-factor authentication and automatic session expiry after 14 days. Access is governed by role-based controls within Teams - Owner, Member, Viewer, and Dashboard roles - while platform-wide admin access is tightly restricted (on FlowFuse Cloud, limited to vetted employees on a need-to-have basis). All administrative and team-level actions are captured in audit logs. On FlowFuse Cloud, data is encrypted at rest with AES-256 (PostgreSQL and AWS EFS), stored in the EU (Ireland), and protected in transit with TLS 1.2 or higher terminated at the edge for both HTTP and MQTT traffic.\n\nOrganizationally, FlowFuse holds SOC 2 Type 1 and Type 2 certifications (audited by Advantage Partners) and maintains formal information security, acceptable use, and data management policies alongside regular security awareness training. The platform is also built to be GxP validation-ready: features like role-based access control, audit logging, instance versioning and snapshots, infrastructure-as-code, and SSO/LDAP integration align with the traceability and change-control expectations of regulated environments such as FDA 21 CFR Part 11 and EU GMP Annex 11, enabling customers to perform their own validation and supplier qualification." - question: "Does FlowFuse offer annual discounts for FlowFuse Cloud?" answer: "Yes, Edge customers receive a free month when paying for a year of FlowFuse in advance. Simply choose yearly billing upon signup, or select it from the Billing area in the app." - question: "Are flat-fee or site-wide licenses available?" answer: "It is not possible at this time to purchase an unlimited-use license, but contact us to discuss pricing for your unique use case." - question: "Do you offer discounts for large-scale deployments?" - answer: "Yes, we provide customized volume discounts for use cases requiring hundreds or thousands of Node-RED instances. Please <a href='/contact-us'>Contact Us</a> to discuss your specific requirements." + answer: "Yes, we provide customized volume discounts for use cases requiring hundreds or thousands of Node-RED instances. Please [Contact Us](/contact-us/) to discuss your specific requirements." diff --git a/nuxt/lib/cta-destinations.test.mjs b/nuxt/lib/cta-destinations.test.mjs index c2b87e45c9..5cfc48d17b 100644 --- a/nuxt/lib/cta-destinations.test.mjs +++ b/nuxt/lib/cta-destinations.test.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' import { createJiti } from 'jiti' const jiti = createJiti(import.meta.url) -const { ctaDestinationKey, ctaQuery, withCtaQuery } = await jiti.import('./cta-destinations.ts') +const { ctaDestinationKey, ctaLink, ctaQuery, withCtaQuery } = await jiti.import('./cta-destinations.ts') const nuxtDir = fileURLToPath(new URL('..', import.meta.url)) test('matches every way of writing a destination URL', () => { @@ -57,6 +57,12 @@ test('a parameter named like a built-in object property round-trips unchanged', assert.equal(withCtaQuery('/pricing/', ctaQuery(href)), href) }) +test('ctaLink gives the destination and query of a destination link, and nothing for any other', () => { + assert.deepEqual(ctaLink('/contact-us/?subject=Certified%20Nodes'), { destination: 'contactUs', query: { subject: 'Certified Nodes' } }) + assert.deepEqual(ctaLink('/pricing'), { destination: 'pricing', query: {} }) + for (const href of ['/docs/', '/pricing/#comparison', 'https://community.flowfuse.com/', undefined]) assert.equal(ctaLink(href), undefined, String(href)) +}) + test('custom CTA registry has no duplicate URLs and none of the five reserved destinations', async () => { await jiti.import('./custom-cta-destinations.ts') }) diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts index ea0c0199df..2e4fcbba2a 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -78,3 +78,9 @@ export function ctaQuery (href: string): CtaQuery { } return { ...query } } + +// What ProseA and InlineMarkdown pass to <CtaLink> for a link to one of the five destinations. +export function ctaLink (href?: string) { + const destination = ctaDestinationKey(href) + return destination ? { destination, query: ctaQuery(href!) } : undefined +} diff --git a/nuxt/lib/faq-answer.mjs b/nuxt/lib/faq-answer.mjs index 94adcc0b00..e0927c9c00 100644 --- a/nuxt/lib/faq-answer.mjs +++ b/nuxt/lib/faq-answer.mjs @@ -1,76 +1,41 @@ -// Renders one FAQ answer into a list of HTML blocks. Kept free of Nuxt and Vue imports so -// it can be unit tested with `node --test`; <BlogFaq> is the only caller. +// Splits one FAQ answer into paragraphs and lists of inline nodes (see inline-markdown.mjs), +// which <Faq> renders. Kept free of Nuxt and Vue imports so it can be unit tested with +// `node --test`. // -// Answers are authored as plain text, and any literal "<...>" in them - "<ip>", "<img>" -// and similar placeholders in the docs-derived answers - must stay literal. So the whole -// string is escaped first, and only a small markdown subset is enabled afterwards, which -// means a URL or a label cannot reintroduce markup. -// -// The subset covers what the 11ty answers expressed with raw tags under `| safe`: -// -// [label](url) inline link, http(s) or site-absolute only -// **bold** <strong> -// *italic* <em> -// blank line a new paragraph (the .njk answers used <p> tags) -// "- item" lines an unordered list (the .njk answers used <ul><li>) -// "1. item" lines an ordered list (the .njk answers used <ol><li>) -const ESCAPE_MAP = { '&': '&', '<': '<', '>': '>', '"': '"' } - -function escapeHtml (text) { - return text.replace(/[&<>"]/g, char => ESCAPE_MAP[char]) -} - -// url is http(s) or site-absolute. Anything else - javascript:, data:, protocol-relative - -// is left as text. -const LINK = /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s/)][^\s)]*|\/)\)/g -// Non-greedy and single-line, so an unclosed marker in prose does not swallow the rest. -const BOLD = /\*\*([^*\n]+)\*\*/g -const ITALIC = /\*([^*\n]+)\*/g - -function inline (text) { - return escapeHtml(text) - // No class: the link takes whatever the page's CSS gives an <a> where it lands - - // Tailwind Typography's link style inside .prose (BlogFaq), `main a` elsewhere. - .replace(LINK, '<a href="$2">$1</a>') - .replace(BOLD, '<strong>$1</strong>') - .replace(ITALIC, '<em>$1</em>') -} +// blank line a new paragraph +// "- item" lines an unordered list +// "1. item" lines an ordered list +import { parseInline } from './inline-markdown.mjs' const UNORDERED_ITEM = /^[-*]\s+(.*)$/ const ORDERED_ITEM = /^\d+[.)]\s+(.*)$/ -function renderList (lines, ordered) { - const tag = ordered ? 'ol' : 'ul' - const pattern = ordered ? ORDERED_ITEM : UNORDERED_ITEM - const items = lines - .map(line => line.match(pattern)?.[1]) - .filter(item => item !== undefined) - .map(item => `<li>${inline(item)}</li>`) - return `<${tag}>${items.join('')}</${tag}>` -} - -// A paragraph made entirely of list-item lines renders as a list; anything else renders as -// a paragraph with its lines joined, which is how the source wrapped long answers. -function renderBlock (paragraph) { +// A paragraph made entirely of list-item lines is a list; anything else is a paragraph with +// its lines joined, which is how the source wrapped long answers. +function parseBlock (paragraph) { const lines = paragraph.split('\n').map(line => line.trim()).filter(Boolean) - if (lines.length && lines.every(line => UNORDERED_ITEM.test(line))) return renderList(lines, false) - if (lines.length && lines.every(line => ORDERED_ITEM.test(line))) return renderList(lines, true) - return inline(lines.join(' ')) + for (const [type, pattern] of [['ul', UNORDERED_ITEM], ['ol', ORDERED_ITEM]]) { + if (lines.length && lines.every(line => pattern.test(line))) { + return { type, items: lines.map(line => parseInline(line.match(pattern)[1])) } + } + } + return { type: 'p', children: parseInline(lines.join(' ')) } } -export { inline } - -export function renderFaqAnswer (answer) { +export function parseFaqAnswer (answer) { if (!answer) return [] return answer .split(/\n\s*\n/) .map(paragraph => paragraph.trim()) .filter(Boolean) - .map(renderBlock) + .map(parseBlock) } -// A block that is a list must not be wrapped in <p>, which is invalid; <BlogFaq> asks -// this rather than sniffing the markup itself. -export function isListBlock (block) { - return block.startsWith('<ul>') || block.startsWith('<ol>') +// The same answer as plain text, for FAQ structured data (JSON-LD): a link keeps only its label, +// and each paragraph and list item goes on its own line. +export function faqAnswerText (answer) { + const text = nodes => nodes.map(node => node.type === 'text' ? node.value : text(node.children)).join('') + return parseFaqAnswer(answer) + .map(block => block.type === 'p' ? text(block.children) : block.items.map(text).join('\n')) + .join('\n\n') } diff --git a/nuxt/lib/faq-answer.test.mjs b/nuxt/lib/faq-answer.test.mjs index a936425f25..fe4f6163f8 100644 --- a/nuxt/lib/faq-answer.test.mjs +++ b/nuxt/lib/faq-answer.test.mjs @@ -1,88 +1,55 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { isListBlock, renderFaqAnswer } from './faq-answer.mjs' +import { faqAnswerText, parseFaqAnswer } from './faq-answer.mjs' -test('escapes markup so placeholders stay literal', () => { - assert.deepEqual(renderFaqAnswer('use <ip> here'), ['use <ip> here']) - assert.deepEqual(renderFaqAnswer('<script>alert(1)</script>'), ['<script>alert(1)</script>']) - assert.deepEqual(renderFaqAnswer('a & b'), ['a & b']) -}) - -test('renders site-absolute and http links', () => { - assert.deepEqual( - renderFaqAnswer('see the [pricing page](/pricing)'), - ['see the <a href="/pricing">pricing page</a>'], - ) - assert.deepEqual( - renderFaqAnswer('[device-agent](https://github.com/FlowFuse/device-agent)'), - ['<a href="https://github.com/FlowFuse/device-agent">device-agent</a>'], - ) -}) - -test('leaves a non-http, non-absolute link target as text', () => { - assert.deepEqual(renderFaqAnswer('[x](javascript:alert(1))'), ['[x](javascript:alert(1))']) -}) - -test('renders bold and italic', () => { - assert.deepEqual(renderFaqAnswer('save you *a lot* of clicking'), ['save you <em>a lot</em> of clicking']) - assert.deepEqual(renderFaqAnswer('**Fleet Mode** deploys'), ['<strong>Fleet Mode</strong> deploys']) -}) - -test('an unclosed marker stays text rather than swallowing the answer', () => { - assert.deepEqual(renderFaqAnswer('2 * 3 is six'), ['2 * 3 is six']) -}) +const text = value => ({ type: 'text', value }) +const p = (...children) => ({ type: 'p', children }) 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'), - ['<ul><li>first</li><li>second</li></ul>'], - ) +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'), - ['<ol><li>go to <a href="/pricing">pricing</a></li><li>pick a plan</li></ol>'], - ) +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'], - ) + assert.deepEqual(parseFaqAnswer('one line\nwrapped onto another'), [p(text('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:', '<ul><li>monitoring</li><li>alerting</li></ul>', 'And more.'], - ) +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.')), + ]) }) -test('isListBlock marks only list blocks', () => { - assert.equal(isListBlock('<ul><li>x</li></ul>'), true) - assert.equal(isListBlock('<ol><li>x</li></ol>'), true) - assert.equal(isListBlock('plain text'), false) - assert.equal(isListBlock('<strong>bold</strong> text'), false) +test('faqAnswerText drops markup and keeps link labels, one line per paragraph or item', () => { + assert.equal( + faqAnswerText('Please [Contact Us](/contact-us/) for **volume** pricing.\n\nUses include:\n\n- *monitoring*\n- alerting'), + 'Please Contact Us for volume pricing.\n\nUses include:\n\nmonitoring\nalerting', + ) + assert.equal(faqAnswerText('use <ip> here'), 'use <ip> here') + assert.equal(faqAnswerText(''), '') }) diff --git a/nuxt/lib/faq-content.test.mjs b/nuxt/lib/faq-content.test.mjs new file mode 100644 index 0000000000..97b5d7fd1d --- /dev/null +++ b/nuxt/lib/faq-content.test.mjs @@ -0,0 +1,72 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import jsYaml from 'js-yaml' + +const nuxtDir = fileURLToPath(new URL('..', import.meta.url)) + +const filesIn = (dir, ext) => readdirSync(dir).flatMap((name) => { + const path = join(dir, name) + if (['.nuxt', '.output', 'node_modules', 'public'].includes(name)) return [] + return statSync(path).isDirectory() ? filesIn(path, ext) : path.endsWith(ext) ? [path] : [] +}) + +const offendingLines = (files, pattern) => files.flatMap(file => readFileSync(file, 'utf8').split('\n') + .flatMap((line, i) => pattern(line) ? [`${relative(nuxtDir, file)}:${i + 1}`] : [])) + +// The argument of each defineQuestion(...) call in a file, up to its closing parenthesis. +const defineQuestionCalls = source => [...source.matchAll(/defineQuestion\(/g)].map(({ index }) => { + let depth = 0 + for (let i = index + 'defineQuestion'.length; i < source.length; i++) { + if (source[i] === '(') depth++ + else if (source[i] === ')' && --depth === 0) return { index, argument: source.slice(index, i + 1) } + } + return { index, argument: source.slice(index) } +}) + +test('FAQ structured data gets the answer as plain text', () => { + const offenders = filesIn(nuxtDir, '.vue').flatMap((file) => { + const source = readFileSync(file, 'utf8') + return defineQuestionCalls(source) + .filter(({ argument }) => !/\b(answer|text):\s*faqAnswerText\(/.test(argument)) + .map(({ index }) => `${relative(nuxtDir, file)}:${source.slice(0, index).split('\n').length}`) + }) + assert.deepEqual(offenders, [], 'Pass faqAnswerText(item.answer) from lib/faq-answer.mjs to defineQuestion, so the JSON-LD carries no markdown.') +}) + +test('no page still renders the retired <BlogFaq>', () => { + const offenders = offendingLines(filesIn(nuxtDir, '.vue'), line => line.includes('<BlogFaq')) + assert.deepEqual(offenders, [], 'BlogFaq was renamed to Faq. An unknown component renders nothing and the build still passes, so that FAQ would be empty.') +}) + +// Synced in from other repositories at build time, and not rendered by Faq. +const SYNCED = ['content/docs', 'content/blueprints'].map(dir => join(nuxtDir, dir) + '/') + +const HTML_TAG = /<\/?(a|br|p|ul|ol|li|strong|em|b|i|span|div)\b[^>]*>/i + +const faqItems = (value) => { + if (Array.isArray(value)) return value.flatMap(faqItems) + if (!value || typeof value !== 'object') return [] + const own = typeof value.question === 'string' && typeof value.answer === 'string' ? [value] : [] + return own.concat(Object.values(value).flatMap(faqItems)) +} + +test('FAQ answers are markdown, not HTML', () => { + const content = filesIn(join(nuxtDir, 'content'), '').filter(file => /\.(md|ya?ml)$/.test(file) && !SYNCED.some(dir => file.startsWith(dir))).flatMap((file) => { + const source = readFileSync(file, 'utf8') + const data = file.endsWith('.md') ? source.match(/^---\n([\s\S]*?)\n---/)?.[1] : source + let parsed + try { + parsed = data ? jsYaml.load(data) : undefined + } catch (error) { + throw new Error(`${relative(nuxtDir, file)}: ${error.message}`) + } + return faqItems(parsed) + .filter(item => HTML_TAG.test(item.answer)) + .map(item => `${relative(nuxtDir, file)}: ${item.question}`) + }) + const pages = offendingLines(filesIn(nuxtDir, '.vue'), line => /\banswer:\s*['"`]/.test(line) && HTML_TAG.test(line)) + assert.deepEqual([...content, ...pages], [], 'Faq shows HTML in an answer as literal text. Write links as [label](url), paragraphs as a blank line, and lists as "- item" lines.') +}) diff --git a/nuxt/lib/inline-markdown.mjs b/nuxt/lib/inline-markdown.mjs new file mode 100644 index 0000000000..a024cf59cd --- /dev/null +++ b/nuxt/lib/inline-markdown.mjs @@ -0,0 +1,45 @@ +// Parses the inline markdown subset used in copy strings and FAQ answers into nodes, which +// <InlineMarkdown> renders. Kept free of Nuxt and Vue imports so it can be unit tested with +// `node --test`. +// +// The output is data, not HTML: Vue escapes text as it renders it, so literal "<...>" - "<ip>", +// "<img>" and similar placeholders in the docs-derived FAQ answers - stays literal. +// +// [label](url) inline link, http(s) or site-absolute only +// **bold** strong +// *italic* em + +// url is http(s) or site-absolute. Anything else - javascript:, data:, protocol-relative - +// is left as text. +const LINK = /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s/)][^\s)]*|\/)\)/ +// Single-line, so an unclosed marker in prose does not swallow the rest. Each one may contain +// the other, so "*a **b** c*" and "**a *b* c**" nest. As in CommonMark, no space follows the +// opening marker or precedes the closing one, so "2 * 3 * 4" stays text. +const BOLD = /\*\*(?=\S)((?:\*[^*\n]+\*|[^*\n])+)(?<=\S)\*\*/ +const ITALIC = /\*(?=\S)((?:\*\*[^*\n]+\*\*|[^*\n])+)(?<=\S)\*/ + +// Earliest match wins; on a tie, the order here. +const MARKERS = [['link', LINK], ['strong', BOLD], ['em', ITALIC]] + +export function parseInline (text) { + const nodes = [] + let rest = text + while (rest) { + let found + for (const [type, pattern] of MARKERS) { + const match = pattern.exec(rest) + if (match && (!found || match.index < found.match.index)) found = { type, match } + } + if (!found) { + nodes.push({ type: 'text', value: rest }) + break + } + const { type, match } = found + if (match.index) nodes.push({ type: 'text', value: rest.slice(0, match.index) }) + nodes.push(type === 'link' + ? { type, href: match[2], children: parseInline(match[1]) } + : { type, children: parseInline(match[1]) }) + rest = rest.slice(match.index + match[0].length) + } + return nodes +} diff --git a/nuxt/lib/inline-markdown.test.mjs b/nuxt/lib/inline-markdown.test.mjs new file mode 100644 index 0000000000..cc27fa4408 --- /dev/null +++ b/nuxt/lib/inline-markdown.test.mjs @@ -0,0 +1,77 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { parseInline } from './inline-markdown.mjs' + +const text = value => ({ type: 'text', value }) + +test('markup stays literal text, for Vue to escape', () => { + assert.deepEqual(parseInline('use <ip> here'), [text('use <ip> here')]) + assert.deepEqual(parseInline('<script>alert(1)</script>'), [text('<script>alert(1)</script>')]) +}) + +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(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('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('nests bold and italic either way round', () => { + assert.deepEqual(parseInline('*a **b** c*'), [ + { type: 'em', children: [text('a '), { type: 'strong', children: [text('b')] }, text(' c')] }, + ]) + assert.deepEqual(parseInline('**a *b* c**'), [ + { type: 'strong', children: [text('a '), { type: 'em', children: [text('b')] }, text(' c')] }, + ]) + assert.deepEqual(parseInline('***x***'), [{ type: 'strong', children: [{ type: 'em', children: [text('x')] }] }]) +}) + +test('two bold spans in one line stay separate', () => { + assert.deepEqual(parseInline('**a** and **b**'), [ + { type: 'strong', children: [text('a')] }, text(' and '), { type: 'strong', children: [text('b')] }, + ]) +}) + +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 rest', () => { + assert.deepEqual(parseInline('2 * 3 is six'), [text('2 * 3 is six')]) +}) + +test('an asterisk with a space on its inner side is not emphasis', () => { + assert.deepEqual(parseInline('2 * 3 * 4'), [text('2 * 3 * 4')]) + assert.deepEqual(parseInline('a ** b ** c'), [text('a ** b ** c')]) + assert.deepEqual(parseInline('*a *b'), [text('*a *b')]) +}) + +test('empty input parses to no nodes', () => { + assert.deepEqual(parseInline(''), []) +}) diff --git a/nuxt/pages/ai/index.vue b/nuxt/pages/ai/index.vue index 4655cbe4c3..35a51d3c26 100644 --- a/nuxt/pages/ai/index.vue +++ b/nuxt/pages/ai/index.vue @@ -1,5 +1,6 @@ <script setup lang="ts"> import { getAllBlogPosts } from '~/utils/sharedContent' +import { faqAnswerText } from '../../lib/faq-answer.mjs' // Ported from src/ai.njk (11ty), which this replaces. Same page, same copy, same // classes from nuxt/assets/css/style.css, which nuxt.config.ts already links here. // @@ -50,7 +51,7 @@ const FAQ = [ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...FAQ.map(item => defineQuestion({ question: item.question, answer: item.answer })), + ...FAQ.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) // The Cloud address. Self-hosted platforms answer on their own domain, so the block @@ -432,7 +433,7 @@ onUnmounted(() => { <h2 class="mb-8">Frequently Asked <span class="text-indigo-600">Questions</span></h2> <!-- The shared accordion, same as the blog and customer stories. It carries the #faqs anchor itself, so the wrapper above no longer sets one. --> - <BlogFaq :faq="FAQ" /> + <Faq :faq="FAQ" /> </div> </div> </div> diff --git a/nuxt/pages/blog/[...slug].vue b/nuxt/pages/blog/[...slug].vue index f3628a4f63..3eb06fd371 100644 --- a/nuxt/pages/blog/[...slug].vue +++ b/nuxt/pages/blog/[...slug].vue @@ -7,6 +7,7 @@ import { getAllBlogPosts } from '../../utils/sharedContent' // even though it works in dev. Passing the components directly skips the registry entirely. import FeatureTierBadges from '../../components/content/FeatureTierBadges.vue' import FeatureReleaseLinks from '../../components/content/FeatureReleaseLinks.vue' +import { faqAnswerText } from '../../lib/faq-answer.mjs' definePageMeta({ layout: 'default' }) @@ -159,7 +160,7 @@ if (routeInfo.value.kind === 'post') { }), computed(() => page.value?.structuredData?.faq?.length ? { '@type': 'FAQPage', - mainEntity: page.value.structuredData.faq.map(item => defineQuestion({ name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })), + mainEntity: page.value.structuredData.faq.map(item => defineQuestion({ name: item.question, acceptedAnswer: { '@type': 'Answer', text: faqAnswerText(item.answer) } })), } : undefined), computed(() => page.value?.structuredData?.howto ? defineHowTo({ name: page.value.structuredData.howto.name || pageTitle.value, @@ -259,7 +260,7 @@ if (routeInfo.value.kind === 'post') { <div v-if="page.structuredData?.faq?.length" class="prose mt-12"> <h2 class="mb-1">Frequently Asked Questions</h2> - <BlogFaq :faq="page.structuredData.faq" /> + <Faq :faq="page.structuredData.faq" /> </div> <BlogAuthorCard v-for="(author, i) in authorMembers" :key="i" :author="author" /> diff --git a/nuxt/pages/customer-stories/[slug].vue b/nuxt/pages/customer-stories/[slug].vue index 6d849602cd..57cd337e1b 100644 --- a/nuxt/pages/customer-stories/[slug].vue +++ b/nuxt/pages/customer-stories/[slug].vue @@ -1,4 +1,6 @@ <script setup lang="ts"> +import { faqAnswerText } from '../../lib/faq-answer.mjs' + const route = useRoute() const slug = route.params.slug as string @@ -75,7 +77,7 @@ useSeoMeta({ useSchemaOrg([ computed(() => page.value?.structuredData?.faq?.length ? { '@type': 'FAQPage', - mainEntity: page.value.structuredData.faq.map(item => defineQuestion({ name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer } })), + mainEntity: page.value.structuredData.faq.map(item => defineQuestion({ name: item.question, acceptedAnswer: { '@type': 'Answer', text: faqAnswerText(item.answer) } })), } : undefined), ]) </script> @@ -119,7 +121,7 @@ useSchemaOrg([ <div v-if="page.structuredData?.faq?.length" class="prose"> <ProseH2>{{ page.structuredData.faqTitle || 'Frequently Asked Questions' }}</ProseH2> - <BlogFaq :faq="page.structuredData.faq" /> + <Faq :faq="page.structuredData.faq" /> </div> </div> diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index 3b6eb3eee7..81ac02b54a 100644 --- a/nuxt/pages/index.vue +++ b/nuxt/pages/index.vue @@ -7,12 +7,13 @@ // inline script that queried the DOM and ran two timers with no teardown. // - testimonials.njk becomes <TestimonialCarousel>, explore-more-content.njk becomes // <ExploreMoreContent> (renamed from ThankYouExploreMore, since this page renders it -// too), social-proof.njk becomes <SocialProof> and faq.njk becomes <BlogFaq>. +// too), social-proof.njk becomes <SocialProof> and faq.njk becomes <Faq>. // - cta-get-started.njk was a four-line partial reading a `cta` object the caller set; // it is the markup at the foot of this page. // - site.messaging.heroTagLine and .subtitle came from src/_data/site.json, which Nuxt // still imports for its own config. They are literals here, so the page reads as the // page. +import { faqAnswerText } from '../lib/faq-answer.mjs' const METRICS = [ { number: '50%', text: 'Reduction in scrap rate with real-time operational monitoring' }, @@ -109,7 +110,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...FAQ.map(item => defineQuestion({ question: item.question, answer: item.answer })), + ...FAQ.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -364,7 +365,7 @@ useSchemaOrg([ <div class="max-w-screen-lg mx-auto"> <h2 class="mb-1">Frequently Asked <span class="text-indigo-600">Questions</span></h2> <div class="-mt-20"> - <BlogFaq :faq="FAQ" variant="page" /> + <Faq :faq="FAQ" variant="page" /> </div> </div> </div> diff --git a/nuxt/pages/integrations/opcua.vue b/nuxt/pages/integrations/opcua.vue index ac2634fef1..af8c2a308a 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: <a href="/blog/2026/05/opc-ua-security-attack-vectors/">how attackers actually exploit these gaps</a> and <a href="/blog/2026/06/opc-ua-security-best-practices/">how to build a defensible architecture</a>.', + 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 <a href="/blog/2024/02/connect-node-red-to-kepware-opc/">Kepware connection guide</a>), or replace it outright. See <a href="/blog/2026/01/kepware-opcua-better-alternative/">Beyond Kepware: Why Modern Industrial Connectivity Demands a Second Look</a>.', + 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<number | null>(null) -function toggleFaq (i: number) { - openFaqIndex.value = openFaqIndex.value === i ? null : i -} </script> <template> @@ -555,30 +548,7 @@ function toggleFaq (i: number) { Frequently asked <span class="text-indigo-600">questions</span> </h2> </div> - <div class="w-full ff-prose"> - <div class="prose max-w-none"> - <div v-for="(item, i) in faq" :key="i" class="w-full py-4" :class="{ 'border-b': i !== faq.length - 1 }"> - <h3 class="not-prose m-0"> - <button - class="question flex flex-row justify-between items-center w-full m-0 p-0 gap-6 cursor-pointer text-left bg-transparent border-0 text-lg font-medium" - type="button" - :aria-expanded="openFaqIndex === i" - @click="toggleFaq(i)" - > - <span>{{ item.question }}</span> - <UIcon - name="i-heroicons-chevron-down" - class="transition-transform ease-in-out duration-300 shrink-0" - :class="{ 'rotate-180': openFaqIndex === i }" - /> - </button> - </h3> - <div v-show="openFaqIndex === i" class="px-6 mt-6"> - <p v-html="item.answer" /> - </div> - </div> - </div> - </div> + <Faq :faq="faq" /> </div> </div> diff --git a/nuxt/pages/landing/plc.vue b/nuxt/pages/landing/plc.vue index e725bc6409..a0ef2ca31f 100644 --- a/nuxt/pages/landing/plc.vue +++ b/nuxt/pages/landing/plc.vue @@ -6,12 +6,13 @@ // - The protocol/use-case glyphs are <UIcon>. They were {% include %}d raw with no // wrapper, filling the wrapper box through the svg's intrinsic ratio; a masked <span> // has none, so each one carries the wrapper's size itself. -// - faq.njk becomes <BlogFaq> plus useSchemaOrg. One answer carried an inline <a> under -// `| safe`; it is a markdown link now, which BlogFaq renders after escaping. +// - faq.njk becomes <Faq> plus useSchemaOrg. One answer carried an inline <a> under +// `| safe`; it is a markdown link now, since Faq renders answer text as text, not HTML. // - The CTA macros become their components. // - This page has both `metaTitle` and `meta.title`; base.njk's precedence put // metaTitle first, so that is the title, rendered as "… | FlowFuse" rather than // "… • FlowFuse" - which is what the metaTitle branch did. +import { faqAnswerText } from '../../lib/faq-answer.mjs' const PROTOCOLS = [ { @@ -249,7 +250,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...FAQ.map(item => defineQuestion({ question: item.question, answer: item.answer })), + ...FAQ.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -525,7 +526,7 @@ useSchemaOrg([ <h2 class="max-md:text-center -mb-12"> Frequently Asked <span class="text-indigo-600">Questions</span> </h2> - <BlogFaq :faq="FAQ" variant="page" /> + <Faq :faq="FAQ" variant="page" /> </div> </div> diff --git a/nuxt/pages/node-red/index.vue b/nuxt/pages/node-red/index.vue index 0bde5d90ca..6d342848a9 100644 --- a/nuxt/pages/node-red/index.vue +++ b/nuxt/pages/node-red/index.vue @@ -14,9 +14,10 @@ // - components/device-agent-install-commands.njk becomes <DeviceAgentInstallCommands>. // - The customer-stories grid read collections.stories sorted by date; it queries the // `stories` collection for the same three. -// - faq.njk becomes <BlogFaq> plus useSchemaOrg. Two answers carried <ol>/<ul> lists and -// several carried inline links, all under `| safe`; they are markdown now, which -// BlogFaq escapes around. Its renderer gained list support for this. +// - faq.njk becomes <Faq> plus useSchemaOrg. Two answers carried <ol>/<ul> lists and +// several carried inline links, all under `| safe`; they are markdown now, since Faq +// renders answer text as text, not HTML. Its parser gained list support for this. +import { faqAnswerText } from '../../lib/faq-answer.mjs' const route = useRoute() const capture = useCapture() @@ -100,7 +101,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...FAQ.map(item => defineQuestion({ question: item.question, answer: item.answer })), + ...FAQ.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -343,7 +344,7 @@ useSchemaOrg([ <h2 class="max-md:text-center -mb-12"> Frequently Asked <span class="text-indigo-600">Questions</span> </h2> - <BlogFaq :faq="FAQ" variant="page" /> + <Faq :faq="FAQ" variant="page" /> </div> </div> </template> diff --git a/nuxt/pages/platform/device-agent.vue b/nuxt/pages/platform/device-agent.vue index e3fce907da..10dd786fd0 100644 --- a/nuxt/pages/platform/device-agent.vue +++ b/nuxt/pages/platform/device-agent.vue @@ -1,5 +1,6 @@ <script setup lang="ts"> // Ported from src/platform/device-agent.njk (11ty). +import { faqAnswerText } from '../../lib/faq-answer.mjs' const FIXES = [ { @@ -171,7 +172,7 @@ const FAQ_DISPLAY = FAQ.map(item => ({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...FAQ.map(item => defineQuestion(item)), + ...FAQ.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -335,7 +336,7 @@ useSchemaOrg([ <div class="max-w-screen-lg mx-auto"> <h2 class="mb-1 text-center md:text-left">Frequently Asked <span class="text-indigo-600">Questions</span></h2> <div class="-mt-20"> - <BlogFaq :faq="FAQ_DISPLAY" variant="page" /> + <Faq :faq="FAQ_DISPLAY" variant="page" /> </div> </div> </div> diff --git a/nuxt/pages/platform/why-flowfuse.vue b/nuxt/pages/platform/why-flowfuse.vue index ccf0c75494..17416b01a5 100644 --- a/nuxt/pages/platform/why-flowfuse.vue +++ b/nuxt/pages/platform/why-flowfuse.vue @@ -7,13 +7,15 @@ // blog. Both render the same video, one for each breakpoint, as the .njk did. // - The four quote glyphs were {% include %}d raw at w-16 with no ff-icon wrapper, so // they resolve through <UIcon> against the installed heroicons set. -// - faq.njk becomes <BlogFaq> plus useSchemaOrg. +// - faq.njk becomes <Faq> plus useSchemaOrg. // - The CTA macros become <CtaBookDemo> and <CtaSignUp>. // - The <title> and meta description are FIXED here. This page's frontmatter carried // MES's meta block verbatim, so production serves // "FlowFuse for your own MES • FlowFuse" and the MES description on // /platform/why-flowfuse/. Carrying that forward knowingly would be worse than the // change, so the title and description now describe this page. +import { faqAnswerText } from '../../lib/faq-answer.mjs' + const VIDEO_ID = 'n9HhZCh0Ndg' const VIDEO_STYLE = `width: 472px; height: 266px; overflow: hidden; background-image: url('https://img.youtube.com/vi/${VIDEO_ID}/maxresdefault.jpg'); background-size: cover; background-position: center;` @@ -54,7 +56,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...FAQ.map(item => defineQuestion({ question: item.question, answer: item.answer })), + ...FAQ.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -167,7 +169,7 @@ useSchemaOrg([ <h2 class="max-md:text-center -mb-12"> Frequently Asked <span class="text-indigo-600">Questions</span> </h2> - <BlogFaq :faq="FAQ" variant="page" /> + <Faq :faq="FAQ" variant="page" /> </div> </div> </div> diff --git a/nuxt/pages/pricing/index.vue b/nuxt/pages/pricing/index.vue index fa794a85fa..b51ed9f5cd 100644 --- a/nuxt/pages/pricing/index.vue +++ b/nuxt/pages/pricing/index.vue @@ -2,6 +2,7 @@ // Shared with the badge lookups, so "has a row here" and "may badge elsewhere" cannot drift. // @ts-ignore untyped module import { onPricing } from '../../lib/feature-catalog.mjs' +import { faqAnswerText } from '../../lib/faq-answer.mjs' const { data: plans } = await useAsyncData('plans', () => queryCollection('plans').order('order', 'ASC').all()) const { data: featureCatalog } = await useAsyncData('featureCatalog', () => queryCollection('featureCatalog').first()) @@ -14,11 +15,6 @@ const tableTiers = computed(() => (plans.value ?? []).map(p => ({ bestFitFor: p.bestFitFor, }))) -const faqAccordionItems = computed(() => (faq.value?.items ?? []).map(item => ({ - label: item.question, - content: item.answer, -}))) - const comparisonSections = computed(() => (featureCatalog.value?.sections ?? []) .map(section => ({ ...section, features: section.features.filter(onPricing) })) .filter(section => section.features.length > 0)) @@ -42,10 +38,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...(faq.value?.items ?? []).map(item => defineQuestion({ - question: item.question, - answer: item.answer, - })), + ...(faq.value?.items ?? []).map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -168,18 +161,7 @@ useSchemaOrg([ <div class="max-w-5xl mx-auto pb-16 px-4"> <div v-if="faq" class="mt-20 mx-auto"> <h2 class="text-center mb-10" v-html="faq.title" /> - <UAccordion - :items="faqAccordionItems" - :ui="{ - trigger: 'text-lg font-medium text-gray-900 py-4 hover:text-indigo-600 transition-colors duration-200', - body: 'text-base text-gray-700 pb-4', - label: 'text-start break-words', - }" - > - <template #body="{ item }"> - <div class="prose max-w-none" v-html="item.content" /> - </template> - </UAccordion> + <Faq :faq="faq.items" /> </div> </div> </div> diff --git a/nuxt/pages/resources/roi-calculator.vue b/nuxt/pages/resources/roi-calculator.vue index 5702a6ecb8..9c3450fdd5 100644 --- a/nuxt/pages/resources/roi-calculator.vue +++ b/nuxt/pages/resources/roi-calculator.vue @@ -2,6 +2,7 @@ // The calculator itself lives in components/RoiCalculator.vue, shared with the // truncated embed on /pricing/. This page wraps it with the framing, the evidence // and the methodology. +import { faqAnswerText } from '../../lib/faq-answer.mjs' // window.capture is injected by the site's analytics script — guarded because it's absent outside production. function capture (eventName?: string, props?: Record<string, unknown>) { @@ -78,7 +79,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...faqs.map(item => defineQuestion(item)), + ...faqs.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -162,7 +163,7 @@ useSchemaOrg([ <section class="w-full px-6 pt-10"> <div class="max-w-screen-lg mx-auto pb-18"> <h2 class="mb-8"><span class="text-indigo-600">ROI questions</span> industrial teams ask</h2> - <BlogFaq :faq="faqs" /> + <Faq :faq="faqs" /> </div> </section> diff --git a/nuxt/pages/use-cases/it-ot-middleware.vue b/nuxt/pages/use-cases/it-ot-middleware.vue index 0d25138e77..b06b42db90 100644 --- a/nuxt/pages/use-cases/it-ot-middleware.vue +++ b/nuxt/pages/use-cases/it-ot-middleware.vue @@ -5,7 +5,7 @@ // What the port changes on purpose: // - layouts/solution.njk's hero becomes <UseCaseSolutionHero>, with the frontmatter // `description` HTML fragment moving into the slot as ordinary template markup. -// - faq.njk becomes <BlogFaq> plus useSchemaOrg, so the FAQ answers are escaped rather +// - faq.njk becomes <Faq> plus useSchemaOrg, so the FAQ answers are escaped rather // than interpolated into a JSON string by hand. // - smooth-scroll.njk's global function and inline onclick are dropped: the site-wide // `scroll-behavior: smooth` in style.css already animates a plain in-page anchor. @@ -17,6 +17,8 @@ // - The reading-list and resources thumbnails point at /blog/**, /whitepaper/** and // /resources/** - assets owned by those sections, in nuxt/public/ at the same paths. // The page's own art is in nuxt/public/images/. +import { faqAnswerText } from '../../lib/faq-answer.mjs' + const capture = useCapture() const FEATURES = [ @@ -65,7 +67,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...FAQ.map(item => defineQuestion({ question: item.question, answer: item.answer })), + ...FAQ.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -153,7 +155,7 @@ useSchemaOrg([ <h2 class="max-md:text-center -mb-12"> Frequently Asked <span class="text-indigo-600">Questions</span> </h2> - <BlogFaq :faq="FAQ" variant="page" /> + <Faq :faq="FAQ" variant="page" /> </div> </div> </div> diff --git a/nuxt/pages/use-cases/remote-device-management.vue b/nuxt/pages/use-cases/remote-device-management.vue index 7428f3e53c..0c487d49c1 100644 --- a/nuxt/pages/use-cases/remote-device-management.vue +++ b/nuxt/pages/use-cases/remote-device-management.vue @@ -5,9 +5,9 @@ // noted per section below. If a second use-case page adopts this structure, lift it into // components/use-case-page/ + a .yml the way industry-page/ was. // -// Copy strings support the same `[label](url)` / **bold** subset as BlogFaq's answers, -// rendered through inline() from lib/faq-answer.mjs (escaped first, so no raw HTML). -import { inline } from '../../lib/faq-answer.mjs' +// Copy strings support the same `[label](url)` / **bold** subset as Faq's answers, +// rendered through <InlineMarkdown>. +import { faqAnswerText } from '../../lib/faq-answer.mjs' // `before` is optional: only the two results that are a change (1 → 130+, 15 → 1) have // one. `company` is pulled out of `text` because the whole card is the link, so the name @@ -152,7 +152,7 @@ useSeoMeta({ useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...FAQ.map(item => defineQuestion(item)), + ...FAQ.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) </script> @@ -234,8 +234,7 @@ useSchemaOrg([ <div v-for="item in VISIBILITY" :key="item.title" class="max-md:text-center"> <UIcon :name="item.icon" class="w-8 h-8 text-indigo-600" /> <p class="text-xl font-semibold text-gray-600 mt-3 mb-0">{{ item.title }}</p> - <!-- eslint-disable-next-line vue/no-v-html --> - <p class="font-light text-gray-700 leading-relaxed mt-6 mb-0" v-html="inline(item.text)" /> + <p class="font-light text-gray-700 leading-relaxed mt-6 mb-0"><InlineMarkdown :text="item.text" position="visibility" /></p> </div> </div> <ProseNote class="mt-16"> @@ -268,8 +267,7 @@ useSchemaOrg([ <div :class="index !== STEPS.length - 1 ? 'pb-10 sm:pb-16' : ''"> <span class="block font-semibold text-gray-500 mb-1 text-center sm:text-left">Step {{ index + 1 }}</span> <h4 class="mt-0 mb-4 text-xl font-semibold text-indigo-600 text-center sm:text-left">{{ step.title }}</h4> - <!-- eslint-disable-next-line vue/no-v-html --> - <p class="font-light text-gray-700 leading-relaxed mb-0" v-html="inline(step.text)" /> + <p class="font-light text-gray-700 leading-relaxed mb-0"><InlineMarkdown :text="step.text" position="connectivity" /></p> </div> </div> </div> @@ -288,8 +286,7 @@ useSchemaOrg([ <UIcon :name="item.icon" class="w-8 h-8 text-indigo-600" /> <p class="text-lg font-semibold text-gray-600 m-0">{{ item.title }}</p> </div> - <!-- eslint-disable-next-line vue/no-v-html --> - <p class="text-sm leading-5 font-light text-gray-700 m-0" v-html="inline(item.text)" /> + <p class="text-sm leading-5 font-light text-gray-700 m-0"><InlineMarkdown :text="item.text" position="intelligence-at-the-edge" /></p> </div> </div> </div> @@ -314,7 +311,7 @@ useSchemaOrg([ <div class="max-w-screen-lg mx-auto"> <h2 class="mb-1 text-center md:text-left">Frequently Asked <span class="text-indigo-600">Questions</span></h2> <div class="-mt-20"> - <BlogFaq :faq="FAQ" variant="page" /> + <Faq :faq="FAQ" variant="page" /> </div> </div> </section> diff --git a/nuxt/pages/webinars/[...slug].vue b/nuxt/pages/webinars/[...slug].vue index 3daff7a464..43f053e153 100644 --- a/nuxt/pages/webinars/[...slug].vue +++ b/nuxt/pages/webinars/[...slug].vue @@ -7,14 +7,15 @@ // <LiteYoutube> and <HubSpotForm>, already shared with the blog and the ebook pages. // - {% renderTeamMember people[host] %} becomes useAuthorMembers + <TeamCardSmall>, the // same resolution the blog byline uses, instead of an 11ty shortcode over a global. -// - faq.njk becomes <BlogFaq> plus useSchemaOrg. The 11ty partial interpolated answers +// - faq.njk becomes <Faq> plus useSchemaOrg. The 11ty partial interpolated answers // into a JSON string by hand; defineQuestion escapes them properly. faq.njk rendered // answers with `| safe`, so a few carried raw <a> tags; those are markdown links now, -// because BlogFaq escapes HTML on purpose (existing answers contain literal "<ip>" -// placeholders that must not become markup). +// because Faq renders answer text as text, not HTML (existing answers contain literal +// "<ip>" placeholders that must not become markup). // - The registration form's "is this still upcoming" test was the dateInFuture filter // (spacetime, today counts as future). isUpcoming below keeps that boundary exactly. import { shortDate } from '../../lib/short-date.mjs' +import { faqAnswerText } from '../../lib/faq-answer.mjs' const route = useRoute() const slug = Array.isArray(route.params.slug) ? route.params.slug.join('/') : route.params.slug @@ -80,7 +81,7 @@ const faq = computed(() => page.value?.structuredData?.faq || []) if (faq.value.length) { useSchemaOrg([ defineWebPage({ '@type': 'FAQPage' }), - ...faq.value.map(item => defineQuestion({ question: item.question, answer: item.answer })), + ...faq.value.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })), ]) } </script> @@ -129,7 +130,7 @@ if (faq.value.length) { <div class="prose"> <h2 class="mb-1">Frequently Asked Questions</h2> </div> - <BlogFaq :faq="faq" /> + <Faq :faq="faq" /> </div> </div>