diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml index 32063b8a..209fa3d4 100644 --- a/.github/workflows/ci-frontend.yml +++ b/.github/workflows/ci-frontend.yml @@ -239,7 +239,7 @@ jobs: - name: Build and push uses: docker/build-push-action@v6 with: - context: frontend/ + context: . file: frontend/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/ci-release.yml b/.github/workflows/ci-release.yml index abbb7150..3d7eb85d 100644 --- a/.github/workflows/ci-release.yml +++ b/.github/workflows/ci-release.yml @@ -81,7 +81,7 @@ jobs: - name: Build and push uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: - context: frontend/ + context: . file: frontend/Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/.mlc_config.json b/.mlc_config.json index a507116f..414f8141 100644 --- a/.mlc_config.json +++ b/.mlc_config.json @@ -8,6 +8,18 @@ }, { "pattern": "^https://github\\.com/mlehotskylf" + }, + { + "pattern": "^/images/", + "comment": "App-relative image paths served by the Nuxt frontend (frontend/public/images/) — not resolvable by offline link checkers" + }, + { + "pattern": "^/for-companies", + "comment": "App-relative route (frontend/app/pages/for-companies.vue) — not resolvable by offline link checkers" + }, + { + "pattern": "^https://crowdfunding\\.linuxfoundation\\.org/images/docs/", + "comment": "Docs screenshot images served from the production LFX Crowdfunding site — not yet available in CI (site not yet deployed to production)" } ], "timeout": "20s", diff --git a/docs/user/backers/index.md b/docs/user/backers/index.md new file mode 100644 index 00000000..b2a17ef0 --- /dev/null +++ b/docs/user/backers/index.md @@ -0,0 +1,36 @@ +--- +title: Backers +description: Understand how supporters are listed on initiative pages on LFX Crowdfunding. +tags: [backers, supporters, donors, donations, anonymous] +last_updated: 2026-06-17 +display_order: 6 +intercom_collection: LFX Crowdfunding +--- + +This page explains how donors are listed on initiative pages. If you want to make a donation, see [Donations](../donations/). + +Every initiative publicly recognises the people and organisations that have contributed to it. Backers are listed in two places on each initiative page. + +## Recent donations sidebar + +The initiative overview shows the most recent donations in a sidebar, including each backer's name, avatar, amount, and how long ago the donation was made. + +## Financials tab + +The **Financials** tab contains a full donations table with each backer's: + +- Name and avatar +- Donor type — **Individual** or **Company** +- Donation amount +- Date + +Use the **Load more** button at the bottom of the table to see older donations. + +## Anonymous donations + +If a donor's information is not available, their name is shown as **Anonymous**. There is no way to filter or exclude anonymous donations from the list. + +## Related sections + +- [Donations](../donations/) — how to make a donation +- [Initiatives](../initiatives/) — browse campaigns diff --git a/docs/user/donations/index.md b/docs/user/donations/index.md new file mode 100644 index 00000000..a1f68a3f --- /dev/null +++ b/docs/user/donations/index.md @@ -0,0 +1,45 @@ +--- +title: Donations +description: Learn how to donate to open source initiatives and manage your giving history on LFX Crowdfunding. +tags: [donations, giving, payments, recurring] +last_updated: 2026-06-17 +display_order: 3 +intercom_collection: LFX Crowdfunding +--- + +Donating to an initiative supports open source projects directly. All payments are processed securely through Stripe. + +> **Sign in required** — Making a donation requires an [LF ID](https://openprofile.dev/) account. You will be prompted to log in before your payment is processed. + +## Making a donation + +1. Open any published [initiative](../initiatives/) page +2. Click the **Donate** button +3. Choose a donation amount or enter a custom amount +4. Select **One-time** or **Monthly** recurring +5. Enter your payment details and confirm + +For a detailed walkthrough, see [Making a Donation](./make-donation/). + +## One-time vs. monthly donations + +**One-time** donations are a single charge processed immediately. + +**Monthly** donations are charged on the same date each month. You can cancel at any time — see [Managing Your Donations](./manage-donations/). + +## Donation history and managing subscriptions + +Your past donations and active monthly subscriptions are managed through the LFX platform. See [Managing Your Donations](./manage-donations/) for details. + +## Email acknowledgement and tax information + +All donations are made to the Linux Foundation, a 501(c)(6) non-profit organisation (a category of non-profit under US tax law). An email acknowledgement is sent automatically after each payment. For tax purposes, please consult your tax adviser regarding the deductibility of your donations. + +## Payment security + +Payments are processed by [Stripe](https://stripe.com). LFX Crowdfunding never stores your full card details. + +## Related sections + +- [Getting Started](../getting-started/) — overview of the platform +- [Initiatives](../initiatives/) — browse campaigns to donate to diff --git a/docs/user/donations/make-donation/index.md b/docs/user/donations/make-donation/index.md new file mode 100644 index 00000000..a60fa5f7 --- /dev/null +++ b/docs/user/donations/make-donation/index.md @@ -0,0 +1,53 @@ +--- +title: Making a Donation +description: How to make a one-time or monthly donation to an open source initiative on LFX Crowdfunding. +tags: [donations, one-time, monthly, payment, stripe] +last_updated: 2026-06-17 +display_order: 1 +intercom_collection: LFX Crowdfunding +--- + +> **Sign in required** — You must be signed in with your [LF ID](https://openprofile.dev/) to make a donation. + +## Starting a donation + +Open any published initiative page and click the **Donate** button. A drawer will open with the donation form. + +## Step 1 — Choose an amount and frequency + +![Donation amount selection](https://crowdfunding.linuxfoundation.org/images/docs/donation-step-amount.png) + +Select a preset amount or enter a custom amount in the input field. + +Then choose your donation frequency: + +- **One-time** — a single charge processed immediately +- **Monthly** — a recurring charge on the same date each month + +Optionally, you can allocate your donation to a specific funding goal (e.g. Development, Marketing, Travel) if the initiative has defined funding categories. By default your donation goes toward all project needs. + +## Step 2 — Payment + +![Donation payment step](https://crowdfunding.linuxfoundation.org/images/docs/donation-step-payment.png) + +Enter your card details: + +- Card number +- Expiry date +- CVC + +If you have previously saved a card, it will be shown here. You can use it directly or click **Use a different card** to enter new details. + +**Order summary** — The drawer shows a breakdown of your donation amount and total. The Linux Foundation underwrites all transaction fees, so 100% of your donation goes to the initiative. + +Click **Donate** to process your payment. For cards that require additional verification (3D Secure — an extra authentication step your bank may require), you may be prompted to authenticate before the payment completes. + +## Anonymous donations + +LFX Crowdfunding does not currently offer a donor-selectable anonymous option. Your name and avatar will be displayed publicly on the initiative's backer list. If your donor information is unavailable at the time of processing, your name will appear as **Anonymous** on the initiative page. + +## After donating + +Once your payment is processed, you will see a confirmation screen with the initiative name and amount. From there you can share your donation on X or LinkedIn, or go to [My Donations on LFX](https://app.lfx.dev/crowdfunding/donations) to view your giving history. + +An email acknowledgement is sent automatically after each successful payment. diff --git a/docs/user/donations/manage-donations/index.md b/docs/user/donations/manage-donations/index.md new file mode 100644 index 00000000..e939bf45 --- /dev/null +++ b/docs/user/donations/manage-donations/index.md @@ -0,0 +1,18 @@ +--- +title: Managing Your Donations +description: How to view your donation history and manage recurring subscriptions on LFX Crowdfunding. +tags: [donations, history, subscriptions, recurring, cancel] +last_updated: 2026-06-17 +display_order: 2 +intercom_collection: LFX Crowdfunding +--- + +Your donation history and recurring subscriptions are managed through the LFX platform. Visit [My Donations on LFX](https://app.lfx.dev/crowdfunding/donations) to access them. + +## Donation history + +The donations page lists all your past one-time and monthly donations, including the initiative name, amount, date, and payment status. + +## Managing recurring donations + +From the same page you can view all active monthly subscriptions and cancel any that you no longer want. Cancellation takes effect immediately and you will not be charged again. diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 00000000..ff48792c --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,39 @@ +--- +title: Getting Started +description: Learn how to use LFX Crowdfunding to support open source projects and communities. +tags: [getting-started, overview, introduction] +last_updated: 2026-06-17 +display_order: 1 +intercom_collection: LFX Crowdfunding +--- + +LFX Crowdfunding is the Linux Foundation's platform for funding open source initiatives. Whether you want to support a project you care about or raise funds for your own community, this guide will help you get started. + +## What is LFX Crowdfunding? + +LFX Crowdfunding connects open source projects with donors and sponsors. Projects can create fundraising initiatives to cover development costs, events, infrastructure, and more. + +## How it works + +1. **Browse initiatives** — explore active fundraising campaigns across the open source ecosystem +2. **Choose a project** — find a project that aligns with your interests or business goals +3. **Make a donation** — contribute one-time or set up a recurring monthly donation +4. **Track your impact** — see how funds are being used through transparent financial reporting + +## For donors + +You can donate to any published initiative directly from its page. Donations are processed securely via Stripe and you will receive an email receipt. + +- One-time donations are accepted in any amount +- Recurring (monthly) donations can be set up and cancelled at any time +- Donations are made to the Linux Foundation, which disburses funds to projects + +## For projects + +If you manage an open source project and want to raise funds, visit the [Create an Initiative](../initiatives/create-initiative/) section to learn how. + +## Related sections + +- [Initiatives](../initiatives/) — browse and understand active campaigns +- [Donations](../donations/) — manage your giving history +- [Create an Initiative](../initiatives/create-initiative/) — start a fundraiser for your project or event diff --git a/docs/user/initiatives/browsing-initiatives/index.md b/docs/user/initiatives/browsing-initiatives/index.md new file mode 100644 index 00000000..d09c12eb --- /dev/null +++ b/docs/user/initiatives/browsing-initiatives/index.md @@ -0,0 +1,51 @@ +--- +title: Browsing Initiatives +description: How to find and explore active fundraising campaigns on LFX Crowdfunding. +tags: [initiatives, browse, search, filter] +last_updated: 2026-06-17 +display_order: 1 +intercom_collection: LFX Crowdfunding +--- + +Go to the **Initiatives** page from the top navigation to see all active campaigns. You can search by name or filter by category. + +![Browse initiatives page](https://crowdfunding.linuxfoundation.org/images/docs/initiatives-browse.png) + +Each initiative card shows: + +- Project name and logo +- Fundraising goal and amount raised so far +- Number of supporters + +## Initiative detail page + +Click any initiative to view its full detail page. + +![Initiative detail page](https://crowdfunding.linuxfoundation.org/images/docs/initiatives-detail.png) + +The detail page includes: + +- **Overview** — the project's mission and how funds will be used +- **Financials** — a breakdown of income and expenses +- **Supporters** — a list of donors (anonymised where requested) + +## Initiative status + +| Status | Description | +|--------|-------------| +| **Submitted** | Application received and awaiting review | +| **Pending** | Under review by the LFX team | +| **Published** | Approved and active — accepting donations | +| **Declined** | Application was not approved | +| **Hidden** | Temporarily removed from public view | + +Only **Published** initiatives are visible to the public. + +## Donating to an initiative + +Use the **Donate** button on any initiative page to make a contribution. See [Donations](../../donations/) for details on the donation process. + +## Related sections + +- [Create an Initiative](../create-initiative/) — start your own campaign +- [Backers](../../backers/) — how supporters are listed on initiative pages diff --git a/docs/user/initiatives/create-initiative/index.md b/docs/user/initiatives/create-initiative/index.md new file mode 100644 index 00000000..5f621ea0 --- /dev/null +++ b/docs/user/initiatives/create-initiative/index.md @@ -0,0 +1,76 @@ +--- +title: Create an Initiative +description: How to create a fundraising initiative for your open source project or event on LFX Crowdfunding. +tags: [initiatives, create, fundraising, project, event] +last_updated: 2026-06-17 +display_order: 2 +intercom_collection: LFX Crowdfunding +--- + +> **Sign in required** — Creating an initiative requires an [LF ID](https://openprofile.dev/) account. + +## Eligibility + +Your project must be related to or affiliated with the Linux Foundation. If you are unsure whether your project qualifies, contact your Linux Foundation programme manager. + +## Starting the form + +Click **Start a Fundraiser** in the header navigation to open the creation form. The form walks you through a series of steps. + +### Step 1 — Choose an initiative type + +![Initiative type selection](https://crowdfunding.linuxfoundation.org/images/docs/fundraising-type-select.png) + +Select the type that best describes your initiative: + +| Type | When to use | +|------|-------------| +| **Project** | An open source software project seeking ongoing funding | +| **Security Audit** | A project seeking funds for a third-party security audit | +| **Event** | A conference, meetup, or community event | +| **General Fund** | General-purpose fundraising not tied to a specific project or event | + +### Step 2 — Initiative details + +Fill in the details for your initiative. Fields vary by type, but all types ask for: + +- **Name** — the public name of your campaign (max 100 characters) +- **Elevator pitch** — a short description of what the funds will support (max 1500 characters) +- **Topic / category** — one or more categories that describe your initiative +- **Logo** — your project or event logo (JPG, PNG, GIF, or WebP; max 2 MB; 600×600 px recommended) +- **Funding goal** — your target amount in USD + +**Project** and **General Fund** initiatives additionally ask for: + +- **Beneficiaries** — the people who will receive the funds (you are added automatically as the primary beneficiary) +- **Fund distribution** — an optional breakdown of how funds will be allocated (e.g. Development, Marketing, Travel) + +**Security Audit** initiatives ask for: + +- **Repository URL** — the codebase to be audited +- **Contact information** — primary, secondary, and technical lead contacts + +**Event** initiatives ask for: + +- **Event dates** — start and end date +- **Registration URL** — link to where attendees can register +- **Location** — city and country (optional) +- **Budget distribution** — an optional breakdown across categories such as Venue, Travel, and Marketing + +![Initiative details form](https://crowdfunding.linuxfoundation.org/images/docs/fundraising-details-form.png) + +### Step 3 — Compliance and terms + +Before submitting, you must confirm: + +- **OFAC compliance** — that your initiative complies with US sanctions regulations. OFAC (the US Office of Foreign Assets Control) administers US sanctions law; you are confirming that your initiative does not involve sanctioned countries, organisations, or individuals. +- **Terms of service** — acceptance of the LFX Platform Use Agreement + +## After submission + +Once submitted, your initiative enters a review queue. The Linux Foundation team will review your application and notify you by email once it is approved and live. You can track the status of your initiative on [My Initiatives on LFX](https://app.lfx.dev/crowdfunding/initiatives). + +## Related sections + +- [Manage Your Initiative](../manage-initiative/) — update your initiative after it is published +- [Reimbursements](../../reimbursements/) — how to set up expense reimbursements for your team diff --git a/docs/user/initiatives/index.md b/docs/user/initiatives/index.md new file mode 100644 index 00000000..368b8ac8 --- /dev/null +++ b/docs/user/initiatives/index.md @@ -0,0 +1,35 @@ +--- +title: Initiatives +description: Learn about fundraising initiatives on LFX Crowdfunding — browse campaigns, create your own, and manage your initiative. +tags: [initiatives, campaigns, projects, fundraising] +last_updated: 2026-06-17 +display_order: 2 +intercom_collection: LFX Crowdfunding +--- + +Initiatives are fundraising campaigns created by open source projects, events, and organisations affiliated with the Linux Foundation. Each initiative has a goal, a description of how funds will be used, and a transparent record of donations received. + +## Initiative types + +| Type | Description | +|------|-------------| +| **Project** | An open source software project seeking ongoing community funding | +| **Security Audit** | A project seeking funds for a third-party security audit | +| **Event** | A conference, meetup, or community event | +| **General Fund** | General-purpose fundraising not tied to a specific project or event | + +## In this section + +- [Browsing Initiatives](./browsing-initiatives/) — find and explore active campaigns +- [Create an Initiative](./create-initiative/) — start a fundraiser for your project or event +- [Manage Your Initiative](./manage-initiative/) — update details and track financial reporting + +## For companies and sponsors + +Companies looking to sponsor open source projects can donate to any published initiative directly from its page. Contact the Linux Foundation for information on sponsorship packages. + +## Related sections + +- [Donations](../donations/) — how to donate and manage your giving +- [Backers](../backers/) — how supporters are listed on initiative pages +- [Reimbursements](../reimbursements/) — how expense reimbursements work diff --git a/docs/user/initiatives/manage-initiative/index.md b/docs/user/initiatives/manage-initiative/index.md new file mode 100644 index 00000000..d1874f8f --- /dev/null +++ b/docs/user/initiatives/manage-initiative/index.md @@ -0,0 +1,30 @@ +--- +title: Manage Your Initiative +description: How to update and manage a published initiative on LFX Crowdfunding. +tags: [initiatives, manage, edit, financials, reporting] +last_updated: 2026-06-17 +display_order: 3 +intercom_collection: LFX Crowdfunding +--- + +Once your initiative is approved and published, you can manage it through the LFX platform. Visit [My Initiatives on LFX](https://app.lfx.dev/crowdfunding/initiatives) to update your initiative's details. + +## What you can update + +From the LFX platform you can update your initiative's name, description, logo, funding goal, beneficiaries, and fund distribution at any time. + +## Financial reporting + +All income and expenses are visible on your initiative's **Financials** tab on LFX Crowdfunding. This includes: + +- Total funds raised +- A breakdown of donations by supporter +- Approved expenses by category + +Financial data is automatically synced from the Linux Foundation's ledger system. + +## Related sections + +- [Create an Initiative](../create-initiative/) — how to submit a new initiative +- [Reimbursements](../../reimbursements/) — how expense reimbursements work for your team +- [Backers](../../backers/) — how supporters are listed on your initiative page diff --git a/docs/user/payment-account/index.md b/docs/user/payment-account/index.md new file mode 100644 index 00000000..a176f664 --- /dev/null +++ b/docs/user/payment-account/index.md @@ -0,0 +1,20 @@ +--- +title: Payment Account +description: How to manage your saved payment method and billing details on LFX Crowdfunding. +tags: [payment, card, billing, stripe] +last_updated: 2026-06-17 +display_order: 5 +intercom_collection: LFX Crowdfunding +--- + +This page applies to donors who have already completed at least one donation on LFX Crowdfunding. + +Payment account management — including updating or removing your saved card — is handled through the LFX platform. Visit [My Donations on LFX](https://app.lfx.dev/crowdfunding/donations) to manage your payment details. + +## Saving a card + +Your card is saved automatically the first time you complete a donation. You do not need to set anything up in advance. + +## Updating your card + +To update your saved card, visit [My Donations on LFX](https://app.lfx.dev/crowdfunding/donations). Your new card will be used for all future donations and active monthly subscriptions. diff --git a/docs/user/reimbursements/index.md b/docs/user/reimbursements/index.md new file mode 100644 index 00000000..ff6b5519 --- /dev/null +++ b/docs/user/reimbursements/index.md @@ -0,0 +1,37 @@ +--- +title: Reimbursements +description: How expense reimbursements work for initiative owners and beneficiaries on LFX Crowdfunding. +tags: [reimbursements, expenses, beneficiaries, expensify] +last_updated: 2026-06-17 +display_order: 7 +intercom_collection: LFX Crowdfunding +--- + +This section applies to initiative owners and beneficiaries claiming expenses against initiative funds. + +Reimbursements allow initiative owners and their designated beneficiaries to claim expenses against initiative funds. The process is managed through an external expense service — LFX Crowdfunding handles the setup and approval flow. + +## Who can submit expenses + +Anyone listed as a **beneficiary** on an initiative can submit expense reports. The initiative owner is automatically a beneficiary. Additional beneficiaries can be added during initiative creation or editing by providing their name and email address. + +## Submitting an expense + +Expense submission is handled externally through the reimbursement service. Once you have been added as a beneficiary on a published initiative, you will receive access to submit expense reports outside of LFX Crowdfunding. + +Expenses are categorised based on the funding goals defined on the initiative (e.g. Development, Marketing, Travel). + +## Approving or rejecting expenses + +When a beneficiary submits an expense report, the initiative owner receives an email with **Approve** and **Reject** links. Clicking either link processes the action immediately — no login is required. + +Approved expenses are reflected in the initiative's **Financials** tab under the expenses breakdown. + +## Viewing expenses + +All approved expenses are visible to anyone on the initiative's **Financials** tab, showing the date, category, description, and amount of each expense. + +## Related sections + +- [Create an Initiative](../initiatives/create-initiative/) — set up an initiative and add beneficiaries +- [Initiatives](../initiatives/) — understand the initiative detail page diff --git a/frontend/.gitignore b/frontend/.gitignore index 7813df43..a3340558 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -22,3 +22,6 @@ node_modules # Playwright playwright-report/ test-results/ + +# Generated docs search index +public/assets/docs/search-index.json diff --git a/frontend/Dockerfile b/frontend/Dockerfile index aebd456e..d1a42ce3 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -12,10 +12,14 @@ WORKDIR /build # Cache pnpm store separately from source — rebuild only when manifest changes. # pnpm-workspace.yaml contains overrides/allowBuilds that affect resolution so # it must be in this layer alongside the lockfile. -COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./ +COPY frontend/pnpm-lock.yaml frontend/package.json frontend/pnpm-workspace.yaml ./ RUN pnpm fetch -COPY . . +# Copy frontend source and docs content so the prebuild script (build-docs.mjs) +# and the runtime server can resolve docs/user/ via ../docs/user relative paths. +COPY frontend/ . +COPY docs/user/ /docs/user/ + RUN pnpm install --frozen-lockfile --prefer-offline && pnpm build # ── Runtime stage ───────────────────────────────────────────────────────────── @@ -27,6 +31,9 @@ WORKDIR /app # Nuxt 4 output: .output/server (Node server) + .output/public (static assets) COPY --from=builder --chown=appuser:appgroup /build/.output/ ./ +# Docs markdown files for runtime API — getDocsDir() finds them via ../docs/user +# (process.cwd() = /app, so ../docs/user = /docs/user) +COPY --from=builder --chown=appuser:appgroup /docs/user/ /docs/user/ USER appuser diff --git a/frontend/app/assets/styles/utils/_docs.scss b/frontend/app/assets/styles/utils/_docs.scss new file mode 100644 index 00000000..3a785bbb --- /dev/null +++ b/frontend/app/assets/styles/utils/_docs.scss @@ -0,0 +1,75 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Extra styles applied to markdown rendered inside documentation article pages. +// Extends .lfx-rich-text with table support and documentation-appropriate heading scale. +.docs-article-body { + @apply text-base leading-7; + + h1 { + @apply text-2xl mt-8 mb-3; + } + + h2 { + @apply text-xl mt-8 mb-2; + } + + h3 { + @apply text-lg mt-6 mb-2; + } + + h4 { + @apply mt-4 mb-1; + } + + p { + @apply mb-4; + } + + ul, + ol { + @apply mb-4 space-y-1.5; + } + + // Callout blockquotes (e.g. "> **Note** — ...") + blockquote { + @apply not-italic border-l-4 border-brand-400 bg-brand-50 text-brand-900 px-4 py-3 my-5 rounded-r-md; + + p { + @apply mb-0; + } + } + + // Markdown tables + table { + @apply w-full border-collapse mb-6 text-sm; + + thead { + th { + @apply border-b-2 border-neutral-200 px-4 py-2.5 text-left font-semibold text-neutral-700 bg-neutral-50; + + &:first-child { + @apply rounded-tl-lg; + } + + &:last-child { + @apply rounded-tr-lg; + } + } + } + + tbody { + tr { + @apply border-b border-neutral-100 transition-colors hover:bg-neutral-50; + + &:last-child { + @apply border-b-0; + } + } + + td { + @apply px-4 py-3 text-neutral-700; + } + } + } +} diff --git a/frontend/app/assets/styles/utils/index.scss b/frontend/app/assets/styles/utils/index.scss index 4d78d9ef..414bba96 100644 --- a/frontend/app/assets/styles/utils/index.scss +++ b/frontend/app/assets/styles/utils/index.scss @@ -6,3 +6,4 @@ @use 'dependency-display'; @use 'badge-shine'; @use 'rich-text'; +@use 'docs'; diff --git a/frontend/app/components/modules/documentation/components/docs-search.vue b/frontend/app/components/modules/documentation/components/docs-search.vue new file mode 100644 index 00000000..3f240ab9 --- /dev/null +++ b/frontend/app/components/modules/documentation/components/docs-search.vue @@ -0,0 +1,163 @@ + + + + + + diff --git a/frontend/app/components/modules/documentation/components/docs-sidebar.vue b/frontend/app/components/modules/documentation/components/docs-sidebar.vue new file mode 100644 index 00000000..6ab6eadb --- /dev/null +++ b/frontend/app/components/modules/documentation/components/docs-sidebar.vue @@ -0,0 +1,85 @@ + + + + + + diff --git a/frontend/app/components/modules/documentation/view/docs-article.vue b/frontend/app/components/modules/documentation/view/docs-article.vue new file mode 100644 index 00000000..6fc79ff2 --- /dev/null +++ b/frontend/app/components/modules/documentation/view/docs-article.vue @@ -0,0 +1,222 @@ + + + + + + diff --git a/frontend/app/components/modules/documentation/view/docs-landing.vue b/frontend/app/components/modules/documentation/view/docs-landing.vue new file mode 100644 index 00000000..4849e93c --- /dev/null +++ b/frontend/app/components/modules/documentation/view/docs-landing.vue @@ -0,0 +1,100 @@ + + + + + + diff --git a/frontend/app/composables/documentation/useDocumentation.ts b/frontend/app/composables/documentation/useDocumentation.ts new file mode 100644 index 00000000..925f45bf --- /dev/null +++ b/frontend/app/composables/documentation/useDocumentation.ts @@ -0,0 +1,24 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { onServerPrefetch } from 'vue'; +import { useQuery } from '@tanstack/vue-query'; +import type { MaybeRefOrGetter } from 'vue'; +import type { DocArticle } from '#shared/types/documentation.types'; + +export function useDocumentation(slug: MaybeRefOrGetter) { + const slugRef = toRef(slug); + + const query = useQuery({ + queryKey: ['docs', 'article', slugRef] as const, + queryFn: () => $fetch(`/api/docs/${slugRef.value}`), + staleTime: 5 * 60 * 1000, + retry: false, + }); + + onServerPrefetch(async () => { + await query.suspense().catch(() => {}); + }); + + return query; +} diff --git a/frontend/app/composables/documentation/useDocumentationNav.ts b/frontend/app/composables/documentation/useDocumentationNav.ts new file mode 100644 index 00000000..e8856a97 --- /dev/null +++ b/frontend/app/composables/documentation/useDocumentationNav.ts @@ -0,0 +1,20 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { onServerPrefetch } from 'vue'; +import { useQuery } from '@tanstack/vue-query'; +import type { DocSectionsResponse } from '#shared/types/documentation.types'; + +export function useDocumentationNav() { + const query = useQuery({ + queryKey: ['docs', 'nav'] as const, + queryFn: () => $fetch('/api/docs'), + staleTime: 5 * 60 * 1000, + }); + + onServerPrefetch(async () => { + await query.suspense(); + }); + + return query; +} diff --git a/frontend/app/config/menu/header.ts b/frontend/app/config/menu/header.ts index 3f36ee7e..b5f22fe3 100644 --- a/frontend/app/config/menu/header.ts +++ b/frontend/app/config/menu/header.ts @@ -26,6 +26,7 @@ export const lfxHeaderMenu: HeaderMenuItem[] = [ { label: 'For Projects', icon: 'laptop-code', to: AppRoute.ForProjects }, { label: 'For Companies', icon: 'buildings', to: AppRoute.ForCompanies }, { label: 'About', icon: 'circle-info', to: AppRoute.About }, + { label: 'Documentation', icon: 'book-open', to: AppRoute.Docs }, ], }, ]; diff --git a/frontend/app/config/routes.ts b/frontend/app/config/routes.ts index 3cbfbb10..fde6f6c8 100644 --- a/frontend/app/config/routes.ts +++ b/frontend/app/config/routes.ts @@ -5,6 +5,7 @@ export enum AppRoute { Home = '/', Initiatives = '/initiatives', Statistics = '/statistics', + Docs = '/docs', ForProjects = '/for-projects', ForCompanies = '/for-companies', About = '/about', diff --git a/frontend/app/pages/docs/[...slug].vue b/frontend/app/pages/docs/[...slug].vue new file mode 100644 index 00000000..f1e485b0 --- /dev/null +++ b/frontend/app/pages/docs/[...slug].vue @@ -0,0 +1,19 @@ + + + + diff --git a/frontend/app/pages/docs/index.vue b/frontend/app/pages/docs/index.vue new file mode 100644 index 00000000..4ac38d52 --- /dev/null +++ b/frontend/app/pages/docs/index.vue @@ -0,0 +1,28 @@ + + + + diff --git a/frontend/package.json b/frontend/package.json index b5c33a73..7510c9d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,9 @@ "type": "module", "packageManager": "pnpm@11.1.2", "scripts": { + "docs:build": "node scripts/build-docs.mjs", + "prebuild": "node scripts/build-docs.mjs", + "predev": "node scripts/build-docs.mjs", "build": "nuxt build", "dev": "nuxt dev", "generate": "nuxt generate", @@ -34,7 +37,10 @@ "chart.js": "^4.5.1", "isomorphic-dompurify": "^2.36.0", "jose": "^6.1.3", + "js-yaml": "^4.2.0", "luxon": "^3.7.2", + "marked": "^18.0.5", + "minisearch": "^7.2.0", "nuxt": "^4.4.8", "ofetch": "^1.5.1", "openid-client": "^6.8.1", @@ -51,6 +57,7 @@ "@eslint/js": "^9.39.2", "@nuxt/test-utils": "^3.23.0", "@playwright/test": "^1.50.0", + "@types/js-yaml": "^4.0.9", "@types/luxon": "^3.7.1", "@types/pluralize": "^0.0.33", "@typescript-eslint/eslint-plugin": "^8.53.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 273c6fee..15151e36 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -64,9 +64,18 @@ importers: jose: specifier: ^6.1.3 version: 6.2.3 + js-yaml: + specifier: ">=4.2.0" + version: 4.2.0 luxon: specifier: ^3.7.2 version: 3.7.2 + marked: + specifier: ^18.0.5 + version: 18.0.5 + minisearch: + specifier: ^7.2.0 + version: 7.2.0 nuxt: specifier: ^4.4.8 version: 4.4.8(@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7))(@parcel/watcher@2.5.6)(@types/node@25.6.2)(@vue/compiler-sfc@3.5.34)(cac@6.7.14)(db0@0.3.4)(esbuild@0.28.1)(eslint@9.39.4(jiti@2.7.0))(ioredis@5.11.1)(magicast@0.5.3)(optionator@0.9.4)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3)))(rolldown@1.0.3)(rollup-plugin-visualizer@7.0.1(rolldown@1.0.3)(rollup@4.62.0))(rollup@4.62.0)(sass@1.99.0)(srvx@0.11.16)(terser@5.48.0)(typescript@5.9.3)(vite@8.0.16(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(yaml@2.9.0))(yaml@2.9.0) @@ -110,6 +119,9 @@ importers: "@playwright/test": specifier: ^1.50.0 version: 1.60.0 + "@types/js-yaml": + specifier: ^4.0.9 + version: 4.0.9 "@types/luxon": specifier: ^3.7.1 version: 3.7.1 @@ -2979,6 +2991,12 @@ packages: integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, } + "@types/js-yaml@4.0.9": + resolution: + { + integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==, + } + "@types/jsesc@2.5.1": resolution: { @@ -7245,6 +7263,14 @@ packages: integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==, } + marked@18.0.5: + resolution: + { + integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==, + } + engines: { node: ">= 20" } + hasBin: true + math-intrinsics@1.1.0: resolution: { @@ -7395,6 +7421,12 @@ packages: } engines: { node: ">=16 || 14 >=14.17" } + minisearch@7.2.0: + resolution: + { + integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==, + } + minizlib@3.1.0: resolution: { @@ -12414,6 +12446,8 @@ snapshots: "@types/estree@1.0.9": {} + "@types/js-yaml@4.0.9": {} + "@types/jsesc@2.5.1": {} "@types/json-schema@7.0.15": {} @@ -15156,6 +15190,8 @@ snapshots: "@babel/types": 7.29.7 source-map-js: 1.2.1 + marked@18.0.5: {} + math-intrinsics@1.1.0: {} mdn-data@2.0.28: {} @@ -15215,6 +15251,8 @@ snapshots: minipass@7.1.3: {} + minisearch@7.2.0: {} + minizlib@3.1.0: dependencies: minipass: 7.1.3 diff --git a/frontend/public/images/docs/donation-step-amount.png b/frontend/public/images/docs/donation-step-amount.png new file mode 100644 index 00000000..ddee3ff6 Binary files /dev/null and b/frontend/public/images/docs/donation-step-amount.png differ diff --git a/frontend/public/images/docs/donation-step-payment.png b/frontend/public/images/docs/donation-step-payment.png new file mode 100644 index 00000000..c308a04c Binary files /dev/null and b/frontend/public/images/docs/donation-step-payment.png differ diff --git a/frontend/public/images/docs/fundraising-details-form.png b/frontend/public/images/docs/fundraising-details-form.png new file mode 100644 index 00000000..2a0f671c Binary files /dev/null and b/frontend/public/images/docs/fundraising-details-form.png differ diff --git a/frontend/public/images/docs/fundraising-type-select.png b/frontend/public/images/docs/fundraising-type-select.png new file mode 100644 index 00000000..d81c21b6 Binary files /dev/null and b/frontend/public/images/docs/fundraising-type-select.png differ diff --git a/frontend/public/images/docs/initiatives-browse.png b/frontend/public/images/docs/initiatives-browse.png new file mode 100644 index 00000000..e8e63cde Binary files /dev/null and b/frontend/public/images/docs/initiatives-browse.png differ diff --git a/frontend/public/images/docs/initiatives-detail.png b/frontend/public/images/docs/initiatives-detail.png new file mode 100644 index 00000000..f02e5353 Binary files /dev/null and b/frontend/public/images/docs/initiatives-detail.png differ diff --git a/frontend/scripts/build-docs.mjs b/frontend/scripts/build-docs.mjs new file mode 100644 index 00000000..9b5bb0e0 --- /dev/null +++ b/frontend/scripts/build-docs.mjs @@ -0,0 +1,84 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +/** + * Build script: scans docs/user/ and writes a search index to + * public/assets/docs/search-index.json for use by the client-side search component. + * + * Run via: pnpm docs:build + * Auto-runs before: pnpm dev, pnpm build + */ + +import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { load as parseYaml } from 'js-yaml'; + +const DOCS_DIR = resolve(import.meta.dirname, '../../docs/user'); +const OUT_FILE = resolve(import.meta.dirname, '../public/assets/docs/search-index.json'); + +function parseFrontmatter(raw) { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return { data: {}, content: raw }; + const data = /** @type {Record} */ (parseYaml(match[1]) ?? {}); + return { data, content: match[2] }; +} + +function stripMarkdown(md) { + return md + .replace(/!\[.*?\]\(.*?\)/g, '') // images + .replace(/\[([^\]]+)\]\(.*?\)/g, '$1') // links → text only + .replace(/`{1,3}[^`\n]*`{1,3}/g, '') // inline code + .replace(/^```[\s\S]*?^```/gm, '') // fenced code blocks + .replace(/^#+\s+/gm, '') // headings + .replace(/[*_~]{1,3}([^*_~\n]+)[*_~]{1,3}/g, '$1') // bold / italic / strikethrough + .replace(/^\s*[-*+>|]\s*/gm, '') // lists, blockquotes, table pipes + .replace(/\n{2,}/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * @param {string} dir + * @param {string} parentSlug + * @returns {Array<{slug: string, title: string, description: string, content: string}>} + */ +function walk(dir, parentSlug = '') { + const docs = []; + + if (!existsSync(dir)) return docs; + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const slug = parentSlug ? `${parentSlug}/${entry.name}` : entry.name; + const indexPath = join(dir, entry.name, 'index.md'); + + if (!existsSync(indexPath)) continue; + + const raw = readFileSync(indexPath, 'utf-8'); + const { data: fm, content } = parseFrontmatter(raw); + + docs.push({ + slug, + title: String(fm.title ?? entry.name), + description: String(fm.description ?? ''), + content: stripMarkdown(content), + }); + + // recurse into sub-sections + docs.push(...walk(join(dir, entry.name), slug)); + } + + return docs; +} + +const docs = walk(DOCS_DIR); + +const outDir = resolve(OUT_FILE, '..'); +if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); + +writeFileSync(OUT_FILE, JSON.stringify(docs)); + +process.stdout.write( + `✓ docs:build — ${docs.length} documents → public/assets/docs/search-index.json\n`, +); diff --git a/frontend/server/api/docs/[...slug].get.ts b/frontend/server/api/docs/[...slug].get.ts new file mode 100644 index 00000000..2d8c02f3 --- /dev/null +++ b/frontend/server/api/docs/[...slug].get.ts @@ -0,0 +1,93 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { readFile } from 'node:fs/promises'; +import { resolve, join, sep } from 'node:path'; +import { marked, Renderer } from 'marked'; +import DOMPurify from 'isomorphic-dompurify'; +import { + getDocsDir, + parseFrontmatter, + rewriteDocLink, + escapeAttr, + formatDate, + toTitleCase, +} from '../../utils/doc-utils'; +import type { DocArticle } from '#shared/types/documentation.types'; + +function buildRenderer(slugDir: string): Renderer { + const renderer = new Renderer(); + renderer.link = ({ href, title, text }) => { + const rewritten = rewriteDocLink(href ?? '', slugDir); + const isExternal = rewritten.startsWith('http://') || rewritten.startsWith('https://'); + const attrs = [ + `href="${escapeAttr(rewritten)}"`, + title ? `title="${escapeAttr(title)}"` : '', + isExternal ? 'target="_blank" rel="noopener noreferrer"' : '', + ] + .filter(Boolean) + .join(' '); + return `${text}`; + }; + return renderer; +} + +export default defineEventHandler(async (event): Promise => { + const slugParts = getRouterParam(event, 'slug'); + + if (!slugParts) { + throw createError({ statusCode: 400, message: 'Missing slug' }); + } + + const normalised = slugParts.toLowerCase().replace(/^\/|\/$/g, ''); + const safeDocsDir = resolve(await getDocsDir()); + + // Build candidate paths then guard against path traversal before any disk access + const candidates = [ + resolve(join(safeDocsDir, `${normalised}.md`)), + resolve(join(safeDocsDir, normalised, 'index.md')), + ]; + + if (candidates.some((c) => !c.startsWith(safeDocsDir + sep))) { + throw createError({ statusCode: 400, message: 'Invalid slug' }); + } + + let raw: string | null = null; + let isIndex = false; + for (const [i, candidate] of candidates.entries()) { + try { + raw = await readFile(candidate, 'utf-8'); + isIndex = i === 1; + break; + } catch { + // try next candidate + } + } + + if (!raw) { + throw createError({ statusCode: 404, message: `Documentation page not found: ${normalised}` }); + } + + // Directory context for resolving relative links: + // index.md files (slug = 'donations') → slugDir = 'donations' + // direct .md files (slug = 'donations/history') → slugDir = 'donations' + const slugDir = isIndex ? normalised : normalised.split('/').slice(0, -1).join('/'); + + const { data: fm, content } = parseFrontmatter(raw); + const renderedHtml = await marked.parse(content, { + renderer: buildRenderer(slugDir), + async: true, + }); + const bodyHtml = DOMPurify.sanitize(renderedHtml); + + return { + slug: normalised, + title: + (fm.title as string | undefined) ?? toTitleCase(normalised.split('/').pop() ?? normalised), + description: (fm.description as string | undefined) ?? '', + bodyHtml, + tags: (fm.tags as string[] | undefined) ?? [], + lastUpdated: fm.last_updated != null ? formatDate(fm.last_updated) : null, + intercomCollection: (fm.intercom_collection as string | undefined) ?? null, + }; +}); diff --git a/frontend/server/api/docs/index.get.ts b/frontend/server/api/docs/index.get.ts new file mode 100644 index 00000000..40fdbe16 --- /dev/null +++ b/frontend/server/api/docs/index.get.ts @@ -0,0 +1,78 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + getDocsDir, + pathExists, + parseFrontmatter, + formatDate, + toTitleCase, +} from '../../utils/doc-utils'; +import type { DocSection, DocSectionsResponse } from '#shared/types/documentation.types'; + +export default defineEventHandler(async (): Promise => { + const docsDir = await getDocsDir(); + + if (!(await pathExists(docsDir))) { + return { sections: [] }; + } + + const entries = await readdir(docsDir, { withFileTypes: true }); + const sectionDirs = entries.filter((e) => e.isDirectory()); + + const sections: DocSection[] = []; + + for (const dir of sectionDirs) { + const indexPath = join(docsDir, dir.name, 'index.md'); + let raw: string; + try { + raw = await readFile(indexPath, 'utf-8'); + } catch { + continue; + } + const { data: fm } = parseFrontmatter(raw); + + // Scan sub-directories for child sections + const subEntries = await readdir(join(docsDir, dir.name), { withFileTypes: true }); + const children: DocSection[] = []; + + for (const subDir of subEntries.filter((e) => e.isDirectory())) { + const subIndexPath = join(docsDir, dir.name, subDir.name, 'index.md'); + let subRaw: string; + try { + subRaw = await readFile(subIndexPath, 'utf-8'); + } catch { + continue; + } + const { data: subFm } = parseFrontmatter(subRaw); + + children.push({ + slug: `${dir.name}/${subDir.name}`, + title: (subFm.title as string | undefined) ?? toTitleCase(subDir.name), + description: (subFm.description as string | undefined) ?? '', + displayOrder: (subFm.display_order as number | undefined) ?? 99, + tags: (subFm.tags as string[] | undefined) ?? [], + lastUpdated: subFm.last_updated != null ? formatDate(subFm.last_updated) : null, + children: [], + }); + } + + children.sort((a, b) => a.displayOrder - b.displayOrder || a.title.localeCompare(b.title)); + + sections.push({ + slug: dir.name, + title: (fm.title as string | undefined) ?? toTitleCase(dir.name), + description: (fm.description as string | undefined) ?? '', + displayOrder: (fm.display_order as number | undefined) ?? 99, + tags: (fm.tags as string[] | undefined) ?? [], + lastUpdated: fm.last_updated != null ? formatDate(fm.last_updated) : null, + children, + }); + } + + sections.sort((a, b) => a.displayOrder - b.displayOrder || a.title.localeCompare(b.title)); + + return { sections }; +}); diff --git a/frontend/server/utils/doc-utils.test.ts b/frontend/server/utils/doc-utils.test.ts new file mode 100644 index 00000000..b712e14d --- /dev/null +++ b/frontend/server/utils/doc-utils.test.ts @@ -0,0 +1,167 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { resolve, join, sep } from 'node:path'; +import { describe, it, expect } from 'vitest'; +import { rewriteDocLink, parseFrontmatter, formatDate, toTitleCase } from './doc-utils'; + +// ── rewriteDocLink ───────────────────────────────────────────────────────── + +describe('rewriteDocLink', () => { + describe('pass-through cases', () => { + it('leaves anchor links unchanged', () => { + expect(rewriteDocLink('#overview', 'initiatives')).toBe('#overview'); + }); + + it('leaves root-relative paths unchanged', () => { + expect(rewriteDocLink('/about', 'initiatives')).toBe('/about'); + }); + + it('leaves http URLs unchanged', () => { + expect(rewriteDocLink('http://example.com', 'initiatives')).toBe('http://example.com'); + }); + + it('leaves https URLs unchanged', () => { + expect(rewriteDocLink('https://example.com/page', 'donations')).toBe( + 'https://example.com/page', + ); + }); + + it('leaves mailto: links unchanged', () => { + expect(rewriteDocLink('mailto:support@example.com', 'initiatives')).toBe( + 'mailto:support@example.com', + ); + }); + + it('leaves tel: links unchanged', () => { + expect(rewriteDocLink('tel:+15551234567', 'initiatives')).toBe('tel:+15551234567'); + }); + }); + + describe('./ same-directory links', () => { + it('rewrites ./child/ inside a section', () => { + expect(rewriteDocLink('./make-donation/', 'donations')).toBe('/docs/donations/make-donation'); + }); + + it('rewrites ./child with no trailing slash', () => { + expect(rewriteDocLink('./make-donation', 'donations')).toBe('/docs/donations/make-donation'); + }); + + it('rewrites ./child.md stripping the extension', () => { + expect(rewriteDocLink('./make-donation.md', 'donations')).toBe( + '/docs/donations/make-donation', + ); + }); + }); + + describe('../ parent-relative links', () => { + it('rewrites single ../ to a sibling of the parent', () => { + // From donations/make-donation, ../ pops make-donation → donations/initiatives + expect(rewriteDocLink('../initiatives/', 'donations/make-donation')).toBe( + '/docs/donations/initiatives', + ); + }); + + it('rewrites double ../../ correctly (two levels up)', () => { + // slug = initiatives/manage-initiative → slugDir = initiatives/manage-initiative (index) + // ../../reimbursements/ should resolve to /docs/reimbursements + expect(rewriteDocLink('../../reimbursements/', 'initiatives/manage-initiative')).toBe( + '/docs/reimbursements', + ); + }); + + it('handles ../ from a top-level section (no parent)', () => { + // Going above the root — parts stack empties gracefully + expect(rewriteDocLink('../other/', 'initiatives')).toBe('/docs/other'); + }); + }); + + describe('bare relative links (no prefix)', () => { + it('treats bare names as same-directory', () => { + expect(rewriteDocLink('make-donation', 'donations')).toBe('/docs/donations/make-donation'); + }); + }); + + describe('cleaning', () => { + it('strips trailing /index.md', () => { + expect(rewriteDocLink('./sub/index.md', 'initiatives')).toBe('/docs/initiatives/sub'); + }); + + it('strips trailing slash', () => { + expect(rewriteDocLink('./sub/', 'initiatives')).toBe('/docs/initiatives/sub'); + }); + }); +}); + +// ── path traversal guard ──────────────────────────────────────────────────── +// This reproduces the guard logic from [...slug].get.ts so the safety property +// is covered by a fast pure test (no filesystem access needed). + +function isSlugSafe(slug: string, docsDir: string): boolean { + const safe = resolve(docsDir); + return [resolve(join(safe, `${slug}.md`)), resolve(join(safe, slug, 'index.md'))].every((c) => + c.startsWith(safe + sep), + ); +} + +describe('slug path traversal guard', () => { + const docsDir = '/srv/docs/user'; + + it('allows a simple top-level slug', () => { + expect(isSlugSafe('initiatives', docsDir)).toBe(true); + }); + + it('allows a nested slug', () => { + expect(isSlugSafe('initiatives/create-initiative', docsDir)).toBe(true); + }); + + it('rejects a slug containing ../', () => { + expect(isSlugSafe('../../etc/passwd', docsDir)).toBe(false); + }); + + it('rejects a slug that escapes docsDir by one level', () => { + expect(isSlugSafe('../other-dir/secret', docsDir)).toBe(false); + }); +}); + +// ── parseFrontmatter ──────────────────────────────────────────────────────── + +describe('parseFrontmatter', () => { + it('extracts YAML data and body', () => { + const raw = `---\ntitle: Hello\n---\nBody text`; + const { data, content } = parseFrontmatter(raw); + expect(data.title).toBe('Hello'); + expect(content).toBe('Body text'); + }); + + it('returns empty data and full text when there is no frontmatter', () => { + const raw = 'Just plain content'; + const { data, content } = parseFrontmatter(raw); + expect(data).toEqual({}); + expect(content).toBe('Just plain content'); + }); +}); + +// ── formatDate ────────────────────────────────────────────────────────────── + +describe('formatDate', () => { + it('formats a Date object to YYYY-MM-DD', () => { + expect(formatDate(new Date('2026-06-16T12:00:00Z'))).toBe('2026-06-16'); + }); + + it('passes through a string date', () => { + expect(formatDate('2026-01-01')).toBe('2026-01-01'); + }); +}); + +// ── toTitleCase ───────────────────────────────────────────────────────────── + +describe('toTitleCase', () => { + it('capitalises each hyphen-separated word', () => { + expect(toTitleCase('getting-started')).toBe('Getting Started'); + }); + + it('handles a single word', () => { + expect(toTitleCase('donations')).toBe('Donations'); + }); +}); diff --git a/frontend/server/utils/doc-utils.ts b/frontend/server/utils/doc-utils.ts new file mode 100644 index 00000000..ebf4392d --- /dev/null +++ b/frontend/server/utils/doc-utils.ts @@ -0,0 +1,100 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +import { access } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { load as parseYaml } from 'js-yaml'; + +async function pathExists(p: string): Promise { + try { + await access(p); + return true; + } catch { + return false; + } +} + +export async function getDocsDir(): Promise { + // Try both common launch points so the server works regardless of CWD. + // Prefer docs/user (fromRoot) so that when started from the repo root the + // correct path is chosen immediately. Fall back to ../docs/user (fromFrontend) + // only when CWD is the frontend/ subdirectory (pnpm dev workflow): + // repo root / CI: process.cwd() = …/repo-root → docs/user is correct + // dev (pnpm dev from frontend/): process.cwd() = …/frontend → ../docs/user is correct + const fromRoot = resolve(process.cwd(), 'docs/user'); + const fromFrontend = resolve(process.cwd(), '../docs/user'); + return (await pathExists(fromRoot)) ? fromRoot : fromFrontend; +} + +export { pathExists }; + +export function parseFrontmatter(raw: string): { data: Record; content: string } { + const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + if (!match) return { data: {}, content: raw }; + const data = (parseYaml(match[1]) ?? {}) as Record; + return { data, content: match[2] }; +} + +// Rewrite relative doc links to absolute /docs/* paths. +// slugDir is the directory context of the current article (set by the route handler): +// - top-level index.md (slug = 'donations') → slugDir = 'donations' +// - nested index.md (slug = 'donations/make-donation') → slugDir = 'donations/make-donation' +// - direct .md file (slug = 'donations/make-donation/guide') → slugDir = 'donations/make-donation' +export function rewriteDocLink(href: string, slugDir: string): string { + // Anchors and root-relative paths pass through unchanged + if (href.startsWith('#') || href.startsWith('/')) { + return href; + } + + // Any URL scheme (http:, https:, mailto:, tel:, etc.) passes through unchanged + if (/^[a-z][a-z0-9+.-]*:/i.test(href)) { + return href; + } + + let path = href; + + if (path.startsWith('../')) { + // Walk up the directory stack for each ../ segment — handles multi-level + const parts = slugDir ? slugDir.split('/') : []; + while (path.startsWith('../')) { + parts.pop(); + path = path.slice(3); + } + path = parts.length ? `${parts.join('/')}/${path}` : path; + } else if (path.startsWith('./')) { + path = slugDir ? `${slugDir}/${path.slice(2)}` : path.slice(2); + } else if (slugDir) { + path = `${slugDir}/${path}`; + } + + const cleaned = path + .replace(/\/index\.md$/, '') + .replace(/\.md$/, '') + .replace(/\/$/, ''); + + return `/docs/${cleaned}`; +} + +// Escape characters that are unsafe inside HTML attribute values. +// Handles both double-quoted and single-quoted attributes, and prevents +// tag injection via angle brackets. +export function escapeAttr(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); +} + +export function formatDate(val: unknown): string { + if (val instanceof Date) return val.toISOString().slice(0, 10); + return String(val).slice(0, 10); +} + +export function toTitleCase(slug: string): string { + return slug + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} diff --git a/frontend/shared/types/documentation.types.ts b/frontend/shared/types/documentation.types.ts new file mode 100644 index 00000000..9aead35f --- /dev/null +++ b/frontend/shared/types/documentation.types.ts @@ -0,0 +1,41 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +export interface DocSection { + slug: string; + title: string; + description: string; + displayOrder: number; + tags: string[]; + lastUpdated: string | null; + children: DocSection[]; +} + +export interface DocSectionsResponse { + sections: DocSection[]; +} + +export interface DocSearchDocument { + slug: string; + title: string; + description: string; + content: string; +} + +export interface DocSearchResult { + id: string; + score: number; + slug: string; + title: string; + description: string; +} + +export interface DocArticle { + slug: string; + title: string; + description: string; + bodyHtml: string; + tags: string[]; + lastUpdated: string | null; + intercomCollection: string | null; +}