diff --git a/apps/landing/src/app/features/page.tsx b/apps/landing/src/app/features/page.tsx index aac66a6..6a3fe0c 100644 --- a/apps/landing/src/app/features/page.tsx +++ b/apps/landing/src/app/features/page.tsx @@ -19,14 +19,20 @@ import { } from "lucide-react"; import { Section, SectionHeader, FeatureCard, Screenshot, CTA } from "@/components/sections"; import { OPENCOM_GITHUB_DOCS_URL, OPENCOM_HOSTED_ONBOARDING_URL } from "@/lib/links"; -import { createLandingPageMetadata } from "@/lib/metadata"; -export const metadata: Metadata = createLandingPageMetadata({ +import { InboxGraphic } from "@/components/landing/graphics/inbox-graphic"; +import { ToursGraphic } from "@/components/landing/graphics/tours-graphic"; +import { OutboundGraphic } from "@/components/landing/graphics/outbound-graphic"; +import { TicketsGraphic } from "@/components/landing/graphics/tickets-graphic"; +import { SurveysGraphic } from "@/components/landing/graphics/surveys-graphic"; +import { CampaignsGraphic } from "@/components/landing/graphics/campaigns-graphic"; +import { ReportsGraphic } from "@/components/landing/graphics/reports-graphic"; + +export const metadata: Metadata = { title: "Features | Opencom", description: "Explore Opencom features across chat, inbox, tours, knowledge base, tickets, surveys, campaigns, reports, and mobile SDKs.", - path: "/features", -}); +}; const featureCategories = [ { @@ -35,7 +41,7 @@ const featureCategories = [ description: "Real-time customer conversations with a shared team inbox and embeddable chat widget.", icon: MessageCircle, - screenshot: "/screenshots/web-inbox.png", + Graphic: InboxGraphic, features: [ "Real-time messaging with typing indicators", "Shared team inbox with snooze and assignment", @@ -50,7 +56,7 @@ const featureCategories = [ title: "Product Tours", description: "Guide users through your product with interactive walkthroughs.", icon: Map, - screenshot: "/screenshots/web-tours.png", + Graphic: ToursGraphic, features: [ "Step-by-step guided tours", "Pointer steps and post steps", @@ -80,7 +86,7 @@ const featureCategories = [ title: "Outbound Messages", description: "Proactively engage users with in-app chats, posts, and banners.", icon: Send, - screenshot: "/screenshots/web-outbound.png", + Graphic: OutboundGraphic, features: [ "In-app chat messages", "Post announcements", @@ -95,7 +101,7 @@ const featureCategories = [ title: "Tickets", description: "Customer support ticketing with priorities, statuses, and custom forms.", icon: Ticket, - screenshot: "/screenshots/web-tickets.png", + Graphic: TicketsGraphic, features: [ "Priority levels (Urgent, High, Normal, Low)", "Status tracking (Submitted, In Progress, Resolved)", @@ -110,7 +116,7 @@ const featureCategories = [ title: "Surveys", description: "Collect feedback and measure customer sentiment.", icon: ClipboardCheck, - screenshot: "/screenshots/web-surveys.png", + Graphic: SurveysGraphic, features: [ "NPS surveys", "Custom satisfaction surveys", @@ -125,7 +131,7 @@ const featureCategories = [ title: "Campaigns", description: "Orchestrate multi-channel outreach campaigns.", icon: Mail, - screenshot: "/screenshots/web-campaigns.png", + Graphic: CampaignsGraphic, features: [ "Email campaigns with templates", "Push notifications", @@ -170,7 +176,7 @@ const featureCategories = [ title: "Reports & Analytics", description: "Analytics and insights for your support operations.", icon: BarChart3, - screenshot: "/screenshots/web-reports.png", + Graphic: ReportsGraphic, features: [ "Conversation volume metrics", "Response and resolution times", @@ -257,7 +263,18 @@ export default function FeaturesPage() {
- + {category.Graphic ? ( +
+ {/* Subtle inner glow */} +
+ +
+ +
+
+ ) : ( + + )}
diff --git a/apps/landing/src/components/landing/features.tsx b/apps/landing/src/components/landing/features.tsx index 9e0ba5f..4476dac 100644 --- a/apps/landing/src/components/landing/features.tsx +++ b/apps/landing/src/components/landing/features.tsx @@ -1,7 +1,6 @@ "use client"; import { motion, Variants } from "framer-motion"; -import { memo } from "react"; import { ChatsCircle, MapTrifold, @@ -11,6 +10,13 @@ import { ChartLineUp, } from "@phosphor-icons/react"; +import { InboxGraphic } from "./graphics/inbox-graphic"; +import { ToursGraphic } from "./graphics/tours-graphic"; +import { TicketsGraphic } from "./graphics/tickets-graphic"; +import { AIAgentGraphic } from "./graphics/ai-graphic"; +import { CampaignsGraphic } from "./graphics/campaigns-graphic"; +import { ReportsGraphic } from "./graphics/reports-graphic"; + const container: Variants = { hidden: { opacity: 0 }, show: { @@ -24,70 +30,48 @@ const item: Variants = { show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 80, damping: 20 } }, }; -// Perpetual Micro-Interaction for Icon Cards -const PerpetualIcon = memo(function PerpetualIcon({ - icon: Icon, - delay = 0, -}: { - icon: React.ElementType; - delay?: number; -}) { - return ( - -
- - - ); -}); - const features = [ { title: "Shared Inbox", description: "Multi-channel support inbox tailored for modern teams. Route, assign, and resolve effortlessly.", icon: ChatsCircle, + Graphic: InboxGraphic, }, { title: "Product Tours", description: "Guide users through your app with native, beautiful onboarding tours that drive activation.", icon: MapTrifold, + Graphic: ToursGraphic, }, { title: "Support Tickets", description: "Track complex issues alongside real-time chat. Seamlessly convert conversations to tickets.", icon: Ticket, + Graphic: TicketsGraphic, }, { title: "AI Agent", description: "Deploy an intelligent agent trained on your docs to instantly resolve common queries 24/7.", icon: Robot, + Graphic: AIAgentGraphic, }, { title: "Outbound Campaigns", description: "Trigger targeted in-app messages and emails based on user behavior and segment rules.", icon: Megaphone, + Graphic: CampaignsGraphic, }, { title: "Analytics", description: "Deep insights into team performance, resolution times, and customer satisfaction metrics.", icon: ChartLineUp, + Graphic: ReportsGraphic, }, ]; @@ -147,15 +131,12 @@ export function Features() { {/* Bento Container */}
{/* Subtle inner glow */} -
- - {/* Abstract background blobs */} -
-
-
+
+ + {/* Full bleed graphic */} +
+
- -
{/* External Labels (Gallery Style) */} diff --git a/apps/landing/src/components/landing/graphics/ai-graphic.tsx b/apps/landing/src/components/landing/graphics/ai-graphic.tsx new file mode 100644 index 0000000..4811f1b --- /dev/null +++ b/apps/landing/src/components/landing/graphics/ai-graphic.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Robot, Sparkle, ChatCircle, Books } from "@phosphor-icons/react"; + +export function AIAgentGraphic() { + return ( +
+ {/* Background Data Nodes */} +
+ {[...Array(12)].map((_, i) => ( + +
+ + ))} +
+ +
+ {/* Connection Lines */} + + + + + {/* Central Brain/Agent */} + +
+
+
+ + +
+ + + {/* Agent Output Simulation */} + +
+ + User: How do I setup SSO? +
+
+
+
+ +
+
+
+
+
+
+
+ + {/* Source Citation */} +
+ + docs/sso-setup.md +
+
+
+
+ +
+
+ ); +} diff --git a/apps/landing/src/components/landing/graphics/campaigns-graphic.tsx b/apps/landing/src/components/landing/graphics/campaigns-graphic.tsx new file mode 100644 index 0000000..5a0f616 --- /dev/null +++ b/apps/landing/src/components/landing/graphics/campaigns-graphic.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { motion } from "framer-motion"; +import { EnvelopeSimple, RocketLaunch, CursorClick } from "@phosphor-icons/react"; + +export function CampaignsGraphic() { + return ( +
+ {/* Background Pipeline Graph */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ {/* Campaign Analytics Card */} + +
+
+ +
+
+

Black Friday Promo

+
+ + Active (Sending) +
+
+
+
+
42%
+
Open Rate
+
+
+ + {/* Campaign Steps */} +
+
+ + {[ + { type: "Email", icon: EnvelopeSimple, label: "Initial Offer", delay: 0.2, active: false }, + { type: "Wait", icon: null, label: "Wait 2 days", delay: 0.4, active: false, small: true }, + { type: "In-App", icon: CursorClick, label: "Reminder Banner", delay: 0.6, active: true }, + ].map((step, i) => ( + +
+ {step.icon ? :
} +
+
+
{step.type}
+
{step.label}
+
+ + ))} +
+
+
+ ); +} diff --git a/apps/landing/src/components/landing/graphics/inbox-graphic.tsx b/apps/landing/src/components/landing/graphics/inbox-graphic.tsx new file mode 100644 index 0000000..4c23e2a --- /dev/null +++ b/apps/landing/src/components/landing/graphics/inbox-graphic.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { motion } from "framer-motion"; +import { User, PaperPlaneRight, DotsThree, CheckCircle, Clock, MagnifyingGlass } from "@phosphor-icons/react"; + +export function InboxGraphic() { + return ( +
+ {/* Sidebar */} +
+
+
+ +
+
+
+
+ {/* Active Conversation */} +
+
+
+ +
+
+
+ Sarah Jenkins + Just now +
+

How do I upgrade my billing plan?

+
+
+ + {/* Other Conversations */} + {[ + { name: "Michael Chen", time: "5m", preview: "The API is throwing a 500 error", status: "open" }, + { name: "Emily Davis", time: "1h", preview: "Thanks! That fixed my issue.", status: "resolved" }, + { name: "Alex Kumar", time: "2h", preview: "Can we get a demo for our team?", status: "open" }, + ].map((chat, i) => ( +
+
+ +
+
+
+ {chat.name} + {chat.time} +
+

{chat.preview}

+
+
+ ))} +
+
+ + {/* Main Chat Area */} +
+ {/* Chat Header */} +
+
+
+ +
+
+

Sarah Jenkins

+
+ + Online • sarah@acmecorp.com +
+
+
+
+
+ + Snooze +
+
+ + Resolve +
+
+ +
+
+
+ + {/* Chat Messages */} +
+
+ Today, 10:42 AM +
+ +
+
+ +
+
+

+ Hi there! We are currently on the Pro plan but we need to add 5 more team members. How do I upgrade my billing plan to Enterprise? +

+
+
+ + +
+ Me +
+
+

+ Hey Sarah! Happy to help with that. You can upgrade directly from your workspace settings. +

+
+
+ + +
+
+
+
+
+
+
+
+
+
+ +
+ + {/* Composer */} +
+
+
+

Reply to Sarah...

+
+
+
+ {[1, 2, 3].map(i => ( +
+
+
+ ))} +
+ +
+
+
+
+
+ ); +} diff --git a/apps/landing/src/components/landing/graphics/outbound-graphic.tsx b/apps/landing/src/components/landing/graphics/outbound-graphic.tsx new file mode 100644 index 0000000..f124da6 --- /dev/null +++ b/apps/landing/src/components/landing/graphics/outbound-graphic.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { motion } from "framer-motion"; +import { PaperPlaneRight, Sparkle } from "@phosphor-icons/react"; + +export function OutboundGraphic() { + return ( +
+ {/* Background Pulse */} + + +
+ {/* Campaign Builder Card */} + +
+
+ +
+
+

New Feature Announcement

+

Target: Active Users (Last 30d)

+
+
+
+
+
+ Delivery Method + In-app Chat +
+
+
+
+
+
+ Message Content +
+
+
+
+
+
+
+ + + {/* Floating AI Suggestion */} + +
+ +
+
+

+ Based on recent usage, sending this on Tuesday at 10 AM will increase open rates by 24%. +

+ +
+
+
+
+ ); +} diff --git a/apps/landing/src/components/landing/graphics/reports-graphic.tsx b/apps/landing/src/components/landing/graphics/reports-graphic.tsx new file mode 100644 index 0000000..f4d94e7 --- /dev/null +++ b/apps/landing/src/components/landing/graphics/reports-graphic.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ChartLineUp, Users, Clock, TrendUp } from "@phosphor-icons/react"; + +export function ReportsGraphic() { + return ( +
+ {/* Dashboard Mockup */} +
+ {/* Header */} +
+
+ + Analytics Overview +
+
+ Last 30 Days +
+
+ +
+ {/* Top Metrics Row */} +
+ +
+ + Total Convos +
+
+ 1,248 + + 12% + +
+
+ + +
+ + Median Response +
+
+ 4m + + 8% + +
+
+
+ + {/* Main Chart Area */} + + Volume over time + + {/* SVG Line Chart Animation */} +
+ {/* Grid lines */} +
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+ + {/* Animated Path */} + + + {/* Area under the curve */} + + + + + + + + +
+ +
+
+
+ ); +} diff --git a/apps/landing/src/components/landing/graphics/surveys-graphic.tsx b/apps/landing/src/components/landing/graphics/surveys-graphic.tsx new file mode 100644 index 0000000..cf84ba8 --- /dev/null +++ b/apps/landing/src/components/landing/graphics/surveys-graphic.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ClipboardText, Star } from "@phosphor-icons/react"; + +export function SurveysGraphic() { + return ( +
+ {/* Decorative Background grid */} +
+ + + {/* Survey Header */} +
+
+
+ +
+

How are we doing?

+

We'd love to hear your feedback on the new dashboard features.

+
+ + {/* Survey Interactive Area */} +
+ {/* NPS Scale Mockup */} +
+
+ {[1, 2, 3, 4, 5].map((num) => ( + + {num} + + ))} +
+
+ Poor + Excellent +
+
+ + {/* Feedback Textarea Mockup */} + +
+
+ +
+ + + {/* Submit Button */} + +
+
+ + {/* Floating Success Toast */} + + + Response recorded + +
+ ); +} diff --git a/apps/landing/src/components/landing/graphics/tickets-graphic.tsx b/apps/landing/src/components/landing/graphics/tickets-graphic.tsx new file mode 100644 index 0000000..95ce1d5 --- /dev/null +++ b/apps/landing/src/components/landing/graphics/tickets-graphic.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { motion } from "framer-motion"; +import { Ticket, CaretDown, ChatTeardropText, CircleDashed } from "@phosphor-icons/react"; + +export function TicketsGraphic() { + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+

Support Tickets

+

3 pending actions

+
+
+
+ New Ticket +
+
+ + {/* Tickets List */} +
+ {[ + { id: "T-4092", title: "Cannot access billing page", status: "High Priority", color: "red", active: true }, + { id: "T-4091", title: "API Rate limit exceeded", status: "In Progress", color: "blue", active: false }, + { id: "T-4090", title: "How to export user data?", status: "Pending", color: "orange", active: false } + ].map((ticket, i) => ( + +
+
+
+
+ {ticket.id} +

{ticket.title}

+
+
+ + {ticket.status} + +
+
+
+
+
+
+
+ + ))} +
+ + {/* Floating Detail Panel */} + +
+ T-4092 +
+ + High Priority +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+ ); +} diff --git a/apps/landing/src/components/landing/graphics/tours-graphic.tsx b/apps/landing/src/components/landing/graphics/tours-graphic.tsx new file mode 100644 index 0000000..e84e73a --- /dev/null +++ b/apps/landing/src/components/landing/graphics/tours-graphic.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { motion } from "framer-motion"; +import { CursorClick, X, Sparkle } from "@phosphor-icons/react"; + +export function ToursGraphic() { + return ( +
+ {/* Background App Mockup */} +
+
+
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+
+
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ + {/* Target Element being Highlighted */} +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ + {/* The Tour Popover */} + + {/* Pointer Triangle */} +
+ +
+
+
+ Step 2 of 4 +
+ +
+ +
+

Configure AI Auto-Resolve

+

+ Train your agent on your documentation. It will automatically resolve repetitive queries and escalate complex issues to your team. +

+ +
+ +
+ + +
+
+
+ + + {/* Floating Mouse Cursor */} + + + + +
+ ); +} diff --git a/apps/landing/src/components/landing/showcase.tsx b/apps/landing/src/components/landing/showcase.tsx index 0cffc67..35c91ca 100644 --- a/apps/landing/src/components/landing/showcase.tsx +++ b/apps/landing/src/components/landing/showcase.tsx @@ -1,22 +1,23 @@ "use client"; import { motion } from "framer-motion"; -import Image from "next/image"; import { useRef } from "react"; +import { InboxGraphic } from "./graphics/inbox-graphic"; +import { ToursGraphic } from "./graphics/tours-graphic"; const showcaseItems = [ { title: "High-performance Inbox", description: "Keyboard-first navigation, real-time sync, and intelligent routing. Built to handle scale without breaking a sweat.", - image: "/screenshots/web-inbox.png", + graphic: InboxGraphic, tourTarget: "showcase-inbox", }, { title: "Native Product Tours", description: "Design multi-step interactive tours directly within your app. No fragile CSS selectors or third-party iframe overlays.", - image: "/screenshots/widget-tour-post-step.png", + graphic: ToursGraphic, tourTarget: "showcase-product-tour", }, ]; @@ -86,7 +87,7 @@ export function Showcase() { transition={{ type: "spring", stiffness: 100, damping: 20 }} className="w-full h-full" > - {item.title} +
diff --git a/openspec/changes/launch-intercom-authority-distribution/.openspec.yaml b/openspec/changes/launch-intercom-authority-distribution/.openspec.yaml new file mode 100644 index 0000000..5aae5cf --- /dev/null +++ b/openspec/changes/launch-intercom-authority-distribution/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-04 diff --git a/openspec/changes/launch-intercom-authority-distribution/design.md b/openspec/changes/launch-intercom-authority-distribution/design.md new file mode 100644 index 0000000..428636a --- /dev/null +++ b/openspec/changes/launch-intercom-authority-distribution/design.md @@ -0,0 +1,103 @@ +## Context + +Competitive query rankings for "Intercom alternatives" are dominated by established domains with strong backlink profiles. Even after on-site intent pages launch, Opencom needs an operational distribution loop to gain third-party mentions, maintain freshness, and prove authority over time. + +This change spans process artifacts in `docs/`, structured tracking data, and a lightweight landing proof surface (`/mentions/`) that supports both trust and crawlable internal linking. + +## Goals / Non-Goals + +**Goals:** +- Define an execution-ready playbook for directory submissions and editorial outreach. +- Standardize outreach assets so claims remain consistent and source-backed. +- Track outreach lifecycle and refresh cadence in machine-readable artifacts. +- Publish a crawlable mentions surface that consolidates earned proof. +- Define measurable SEO checkpoints for iteration. + +**Non-Goals:** +- Automating third-party submissions end-to-end. +- Building a full CRM/revenue attribution platform. +- Guaranteeing placements from external publishers. + +## Decisions + +### 1) Store authority operations as versioned docs + machine-readable tracker + +Decision: +- Add human-readable playbook/template docs and a tracker artifact (CSV or JSON) under `docs/seo/`. + +Rationale: +- Versioned repo artifacts provide auditability, collaboration, and compatibility with simple scripts/reporting. + +Alternatives considered: +- Tracking only in ad-hoc spreadsheets: rejected due to poor reviewability and weak change history. + +### 2) Define a minimum outreach asset pack with evidence constraints + +Decision: +- Standardize required outreach snippets: positioning paragraph, differentiators, license/deployment facts, and links to comparison/migration pages. + +Rationale: +- Outreach consistency prevents contradictory claims and accelerates submission throughput. + +Alternatives considered: +- Free-form outreach copy per contributor: rejected due to quality variance. + +### 3) Build `/mentions/` from structured mention entries + +Decision: +- Represent mentions as structured entries with source URL, publication name, verification state, and timestamp; render from this data source. + +Rationale: +- Structured entries make verification explicit and support deterministic UI rendering plus freshness metadata. + +Alternatives considered: +- Hardcoded mentions in JSX: rejected because it does not scale and is error-prone during updates. + +### 4) Use periodic refresh checkpoints tied to intent pages + +Decision: +- Add review cadence requirements for alternatives/comparison pages and tie them to tracker entries with next-action dates. + +Rationale: +- Competitive pages degrade without recurring updates; tracking dates enforces ongoing maintenance. + +Alternatives considered: +- One-time launch without refresh SLA: rejected due to expected ranking decay. + +### 5) Keep measurement intentionally lightweight at first + +Decision: +- Start with a compact KPI set (impressions, clicks, CTR for target queries + one conversion-aligned metric) and refine later. + +Rationale: +- Keeps the program operable immediately without waiting for a complex analytics stack. + +Alternatives considered: +- Full multi-touch attribution before launch: rejected as too slow for immediate ranking work. + +## Risks / Trade-offs + +- [Risk] Outreach tracker becomes stale and loses trust. + - Mitigation: enforce owner and next-action fields with status transitions. +- [Risk] Public mentions page lists unverified or low-quality references. + - Mitigation: include verification state and only render verified entries. +- [Risk] Overly aggressive claims in outreach damage credibility. + - Mitigation: require source-backed claims and explicit available-now vs roadmap labels. + +## Migration Plan + +1. Add `docs/seo/` playbook, outreach templates, and authority tracker artifact. +2. Add structured mention data model and implement `/mentions/` route. +3. Add nav/footer links to mentions page where appropriate. +4. Backfill initial targets and mention entries from existing known placements. +5. Start weekly authority update loop and log KPI snapshots. + +Rollback: +- Keep docs/tracker artifacts intact and hide `/mentions/` route if needed. +- Re-enable route after data cleanup without losing process documentation. + +## Open Questions + +- Which team role owns weekly tracker updates after initial launch? +- Should rejected outreach targets remain visible in the same tracker file or be archived separately? +- Do we need a strict minimum domain-quality threshold for mentions before display? diff --git a/openspec/changes/launch-intercom-authority-distribution/proposal.md b/openspec/changes/launch-intercom-authority-distribution/proposal.md new file mode 100644 index 0000000..ddac15c --- /dev/null +++ b/openspec/changes/launch-intercom-authority-distribution/proposal.md @@ -0,0 +1,32 @@ +## Why + +On-page SEO pages alone are unlikely to reach page 1 quickly for competitive "Intercom alternatives" queries without off-site authority, repeat mentions, and a disciplined refresh cycle. Opencom needs a repeatable distribution system that turns new comparison/migration assets into backlinks, directory placements, and ongoing ranking signals. + +## What Changes + +- Add an SEO distribution playbook in-repo with: + - target directories and listicles, + - submission/outreach templates, + - evidence pack requirements (license, hosting model, feature matrix, migration links). +- Add a tracking system for authority work (submission status, backlinks earned, follow-ups, next refresh date). +- Add a `mentions` surface on the landing site so earned placements can be showcased and internally linked. +- Define a refresh cadence for comparison/list pages and supporting assets to avoid stale rankings. +- Define ranking and conversion measurement checkpoints for this cluster (query impressions, clicks, CTR, assisted conversions). +- Add quality guardrails for public comparisons: objective criteria, source-backed claims, and explicit labels for "available now" vs "roadmap". + +## Capabilities + +### New Capabilities +- `seo-distribution-playbook-and-asset-pack`: Repo contains an execution-ready playbook and reusable asset pack for directory submissions and alternatives-list outreach. +- `seo-authority-tracking-and-refresh-cycle`: Repo defines and tracks outreach, backlinks, and refresh checkpoints so ranking work is continuous instead of one-off. +- `mentions-proof-surface`: Landing app exposes a crawlable mentions/proof surface that consolidates earned placements and links back into conversion pages. + +### Modified Capabilities +- None. + +## Impact + +- New process docs/templates/tracking artifacts under `docs/**`. +- New landing route(s) in `apps/landing/src/app/**` for mentions/proof. +- Potential updates to nav/footer/internal links for discoverability. +- Ongoing cross-functional workflow between product marketing and engineering for content freshness. diff --git a/openspec/changes/launch-intercom-authority-distribution/specs/mentions-proof-surface/spec.md b/openspec/changes/launch-intercom-authority-distribution/specs/mentions-proof-surface/spec.md new file mode 100644 index 0000000..426e4ec --- /dev/null +++ b/openspec/changes/launch-intercom-authority-distribution/specs/mentions-proof-surface/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: Landing app MUST expose a crawlable mentions proof route +The landing app SHALL publish a crawlable mentions page that consolidates verified third-party placements and references. + +#### Scenario: Mentions route renders verified entries +- **WHEN** `/mentions/` is rendered +- **THEN** it SHALL list only verified third-party mentions with source link, publication name, and publication date when known + +#### Scenario: Mentions route exposes recency context +- **WHEN** the mentions page is viewed +- **THEN** it SHALL display a "last updated" timestamp for the mentions dataset + +### Requirement: Mentions proof route MUST reinforce SEO cluster navigation +The mentions page SHALL route users back to core conversion pages in the Intercom cluster. + +#### Scenario: Users can continue evaluation from mentions +- **WHEN** a visitor scans a mention entry +- **THEN** the page SHALL provide internal links to `/intercom-alternative/` and `/compare/intercom/` +- **AND** at least one call-to-action to hosted onboarding or docs SHALL be present diff --git a/openspec/changes/launch-intercom-authority-distribution/specs/seo-authority-tracking-and-refresh-cycle/spec.md b/openspec/changes/launch-intercom-authority-distribution/specs/seo-authority-tracking-and-refresh-cycle/spec.md new file mode 100644 index 0000000..ed65cef --- /dev/null +++ b/openspec/changes/launch-intercom-authority-distribution/specs/seo-authority-tracking-and-refresh-cycle/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: Authority operations MUST be tracked in a shared status artifact +The repository SHALL include a machine-readable tracker for outreach and directory placement work. + +#### Scenario: Tracker captures lifecycle status +- **WHEN** a directory or listicle target is added +- **THEN** the tracker SHALL record target URL, status, owner, and next action date +- **AND** the tracker SHALL support states for not-started, submitted, in-review, live, and rejected + +### Requirement: Comparison and alternatives assets MUST follow a refresh cadence +The SEO cluster SHALL include documented refresh rules so ranking pages do not become stale. + +#### Scenario: Refresh dates are explicit +- **WHEN** a comparison or alternatives page is published +- **THEN** the associated tracker entry SHALL include a next review date +- **AND** the page content model SHALL expose a visible "last reviewed" timestamp + +#### Scenario: Refresh updates are scoped and auditable +- **WHEN** content is refreshed +- **THEN** the update SHALL document what changed in comparison criteria, competitor set, or capability status labels + +### Requirement: Authority program MUST define measurable checkpoints +The distribution workflow SHALL define measurable ranking and conversion checkpoints for the Intercom cluster. + +#### Scenario: KPI set is complete +- **WHEN** reporting is produced for this program +- **THEN** it SHALL include impressions, clicks, and CTR for target queries +- **AND** it SHALL include at least one conversion-aligned metric from cluster routes diff --git a/openspec/changes/launch-intercom-authority-distribution/specs/seo-distribution-playbook-and-asset-pack/spec.md b/openspec/changes/launch-intercom-authority-distribution/specs/seo-distribution-playbook-and-asset-pack/spec.md new file mode 100644 index 0000000..71b19d0 --- /dev/null +++ b/openspec/changes/launch-intercom-authority-distribution/specs/seo-distribution-playbook-and-asset-pack/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Repo MUST include an executable Intercom-alternative distribution playbook +The repository SHALL include a documented playbook that operationalizes directory submissions and alternatives-list outreach for the Intercom SEO cluster. + +#### Scenario: Playbook defines prioritized target tiers +- **WHEN** the playbook is read +- **THEN** it SHALL group targets into priority tiers with qualification criteria +- **AND** each target SHALL include the recommended asset link for submission + +#### Scenario: Playbook defines explicit owner handoffs +- **WHEN** a distribution task is initiated +- **THEN** the playbook SHALL identify ownership for research, outreach, and follow-up actions +- **AND** the completion definition for each action SHALL be documented + +### Requirement: Repo MUST include a reusable outreach asset pack +The repository SHALL provide reusable templates and evidence snippets to keep outbound submissions consistent and source-backed. + +#### Scenario: Templates support multiple outreach channels +- **WHEN** contributors prepare outreach +- **THEN** the repo SHALL provide at least one template for directory submissions and one template for editorial/listicle outreach + +#### Scenario: Evidence pack enforces factual consistency +- **WHEN** an outreach template references product claims +- **THEN** it SHALL source those claims from maintained Opencom references (for example licensing, deployment model, and comparison pages) +- **AND** unsupported parity claims SHALL be explicitly excluded diff --git a/openspec/changes/launch-intercom-authority-distribution/tasks.md b/openspec/changes/launch-intercom-authority-distribution/tasks.md new file mode 100644 index 0000000..9671156 --- /dev/null +++ b/openspec/changes/launch-intercom-authority-distribution/tasks.md @@ -0,0 +1,29 @@ +## 1. Distribution Playbook And Templates + +- [ ] 1.1 Create `docs/seo/` playbook documenting target tiers, qualification criteria, and owner handoffs for directory/listicle outreach. +- [ ] 1.2 Create reusable outreach templates for directory submissions and editorial outreach. +- [ ] 1.3 Create an evidence pack section with approved claim snippets and required links (license, deployment, comparison, migration). + +## 2. Authority Tracker And Refresh Workflow + +- [ ] 2.1 Add a machine-readable tracker artifact for target URL, owner, status, next action date, and notes. +- [ ] 2.2 Define status transition rules (`not-started`, `submitted`, `in-review`, `live`, `rejected`) in playbook docs. +- [ ] 2.3 Add refresh cadence rules and ensure alternatives/comparison assets have review-date fields. + +## 3. Mentions Proof Surface + +- [ ] 3.1 Add structured mentions data model with source URL, publication name, verification flag, and timestamps. +- [ ] 3.2 Implement `/mentions/` landing route that renders only verified entries and displays last updated timestamp. +- [ ] 3.3 Add links from `/mentions/` to `/intercom-alternative/`, `/compare/intercom/`, and onboarding/docs CTAs. + +## 4. Program Measurement And Reporting + +- [ ] 4.1 Define reporting template covering impressions, clicks, CTR, and at least one conversion-aligned metric. +- [ ] 4.2 Document weekly operating cadence for tracker updates, outreach follow-ups, and KPI review. +- [ ] 4.3 Seed tracker with an initial target set and baseline status snapshot. + +## 5. Verification + +- [ ] 5.1 Validate docs/template completeness against spec requirements. +- [ ] 5.2 Validate mentions page rendering and internal links. +- [ ] 5.3 Run landing package quality checks (typecheck/lint/tests as available) for any app changes. diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/.openspec.yaml b/openspec/changes/rank-intercom-alternative-intent-cluster/.openspec.yaml new file mode 100644 index 0000000..5aae5cf --- /dev/null +++ b/openspec/changes/rank-intercom-alternative-intent-cluster/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-04 diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/design.md b/openspec/changes/rank-intercom-alternative-intent-cluster/design.md new file mode 100644 index 0000000..6428311 --- /dev/null +++ b/openspec/changes/rank-intercom-alternative-intent-cluster/design.md @@ -0,0 +1,103 @@ +## Context + +`apps/landing` currently includes a homepage plus broad informational pages (`/features`, `/docs`, etc.) but no dedicated route cluster for high-intent Intercom-alternative queries. Metadata utilities exist (`createLandingPageMetadata`) and can be extended, but there is no route-specific structured-data pattern for FAQ/breadcrumb output and no explicit SEO cluster linking contract. + +This change is cross-cutting across route creation, shared content models, metadata generation, and global navigation/linking. + +## Goals / Non-Goals + +**Goals:** +- Launch an intent-mapped Intercom SEO cluster with dedicated pages for key query families. +- Add trust/evaluation pages (`/compare/intercom/`, `/migrate-from-intercom/`) with clear next-step CTAs. +- Prevent keyword cannibalization by enforcing one primary query intent per page. +- Standardize metadata and structured data output for the cluster. +- Keep comparison content maintainable via reusable data structures rather than hardcoded JSX fragments. + +**Non-Goals:** +- Building a full blog CMS in this change. +- Reworking brand-wide design language outside the targeted routes. +- Claiming complete parity with all competitor capabilities where evidence does not exist. + +## Decisions + +### 1) Use route-per-intent architecture under `apps/landing/src/app` + +Decision: +- Implement dedicated route directories for each query target, including nested routes for `/compare/intercom/`. + +Rationale: +- Query intent isolation improves relevance and reduces cannibalization from having one generic page for all intents. + +Alternatives considered: +- Single long-form page with hash sections: rejected because it weakens intent targeting and snippet control. + +### 2) Introduce a shared Intercom cluster content model + +Decision: +- Create shared data/config modules for competitor rows, comparison dimensions, FAQ items, and freshness metadata. + +Rationale: +- Shared models reduce drift between `/free-intercom-alternatives/` and `/compare/intercom/` while making periodic refresh work low-friction. + +Alternatives considered: +- Hand-author each page in JSX only: rejected due to high maintenance risk and inconsistent claim formatting. + +### 3) Enforce evidence-backed comparison status labels + +Decision: +- Comparison rows include explicit status labels (`available now`, `roadmap`) and source references derived from internal competitive analysis artifacts. + +Rationale: +- The provided competitive analysis includes broad capability coverage and should constrain public claims to defensible statements. + +Alternatives considered: +- Marketing-only claim language without source constraints: rejected due to trust and regression risk. + +### 4) Add explicit metadata and schema primitives per route + +Decision: +- Extend metadata helpers and add JSON-LD helper patterns for FAQ/Breadcrumb output, with strict parity between visible content and schema fields. + +Rationale: +- Improves eligibility for rich result enhancements while avoiding schema mismatch issues. + +Alternatives considered: +- Schema-free implementation: rejected because it leaves obvious SEO wins on the table. + +### 5) Define cluster-level internal linking contract + +Decision: +- Require reciprocal linking among core intent pages plus prominent hub links from `/features` and shared nav/footer surfaces where appropriate. + +Rationale: +- Internal-link graph clarity is required for crawl depth and conversion path continuity. + +Alternatives considered: +- Only contextual in-body links: rejected because links may regress during copy edits. + +## Risks / Trade-offs + +- [Risk] Page overlap creates keyword cannibalization. + - Mitigation: enforce intent-to-route mapping and distinct H1/title requirements in specs. +- [Risk] Comparison claims drift from current product capability. + - Mitigation: centralize data with explicit source/status fields and add refresh timestamp requirements. +- [Risk] Structured data can diverge from visible FAQ text. + - Mitigation: generate JSON-LD from the same content source used to render visible FAQs. + +## Migration Plan + +1. Add shared content and metadata/schema helpers for the new cluster. +2. Implement route pages in this order: `/intercom-alternative/`, `/open-source-intercom-alternative/`, `/free-intercom-alternatives/`, `/compare/intercom/`, `/migrate-from-intercom/`. +3. Update `/features`, nav, and footer linking to support cluster traversal. +4. Add sitemap/robots coverage and verify cluster URL discoverability. +5. Run route-level QA for metadata, structured data parity, and internal link integrity. + +Rollback: +- Revert cluster routes while retaining shared helper modules if needed. +- Keep legacy pages and navigation paths unchanged if rollback occurs. + +## Open Questions + +- Should `/open-source-intercom/` be added as a redirect alias to `/open-source-intercom-alternative/` for query coverage, or should we keep one canonical route only? +- Should the free alternatives page include scoring weights, or only qualitative comparison plus trade-offs? +- Do we want page-level analytics event hooks in this change, or defer to the authority-tracking change? diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/proposal.md b/openspec/changes/rank-intercom-alternative-intent-cluster/proposal.md new file mode 100644 index 0000000..474deea --- /dev/null +++ b/openspec/changes/rank-intercom-alternative-intent-cluster/proposal.md @@ -0,0 +1,35 @@ +## Why + +Opencom already positions itself as an open-source Intercom alternative, but it lacks dedicated pages mapped to high-intent search queries (for example, "intercom alternative", "free intercom alternatives", and "open source intercom alternative"). As a result, ranking opportunities are being captured by listicles and directories even when Opencom is a direct fit. + +## What Changes + +- Add a search-intent landing cluster in `apps/landing` with dedicated routes: + - `/intercom-alternative/` + - `/free-intercom-alternatives/` + - `/open-source-intercom-alternative/` +- Add trust and evaluation routes: + - `/compare/intercom/` for feature, deployment, pricing model, and governance comparison + - `/migrate-from-intercom/` for an actionable migration guide and expected rollout timeline +- Upgrade `/features` into a cluster hub by adding prominent internal links to comparison and migration content. +- Add reusable comparison content models so pages are consistent, evidence-backed, and easier to refresh. +- Add metadata and structured data requirements for cluster pages (distinct title/H1 intent targeting, canonical paths, FAQ JSON-LD on eligible pages, breadcrumb schema where helpful). +- Add crawl discoverability requirements (sitemap/robots coverage for all cluster routes). +- Add freshness cues on list/comparison pages (for example "last reviewed") so content can be updated without structural rewrites. + +## Capabilities + +### New Capabilities +- `intercom-alternative-intent-pages`: Landing app serves dedicated, intent-specific Intercom alternative pages with clear query-to-page mapping and conversion paths. +- `intercom-comparison-and-migration-pages`: Landing app serves deep comparison and migration guidance pages that support evaluation intent and reduce conversion friction. +- `landing-seo-cluster-linking-and-schema`: Landing app enforces canonical metadata, structured data, and internal-link graph rules across the Intercom intent cluster. + +### Modified Capabilities +- None. + +## Impact + +- New and updated landing routes under `apps/landing/src/app/**`. +- Shared metadata/content helpers under `apps/landing/src/lib/**`. +- Navigation/footer/feature-page internal links under `apps/landing/src/components/**`. +- QA scope for route rendering, metadata output, and crawl discoverability. diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-alternative-intent-pages/spec.md b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-alternative-intent-pages/spec.md new file mode 100644 index 0000000..74548d6 --- /dev/null +++ b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-alternative-intent-pages/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Landing app MUST provide dedicated Intercom-alternative intent routes +The landing app SHALL expose dedicated routes for high-intent Intercom-alternative queries instead of relying on the homepage only. + +#### Scenario: Intent routes are available +- **WHEN** a user requests `/intercom-alternative/`, `/free-intercom-alternatives/`, or `/open-source-intercom-alternative/` +- **THEN** the app SHALL render a first-class page for each route with an HTTP 200 response + +#### Scenario: Each route has a single primary intent +- **WHEN** a page in this cluster is rendered +- **THEN** the page SHALL contain one clear H1 aligned to its target query family +- **AND** the introductory copy SHALL focus on that query intent instead of duplicating another page's positioning + +### Requirement: Free alternatives route MUST deliver listicle-grade utility +The `/free-intercom-alternatives/` route SHALL provide practical, neutral utility that can compete with third-party listicles. + +#### Scenario: Alternatives list includes market coverage +- **WHEN** `/free-intercom-alternatives/` is published +- **THEN** it SHALL include at least eight named alternatives +- **AND** Opencom SHALL be evaluated with the same comparison dimensions as other options + +#### Scenario: Comparison methodology is visible +- **WHEN** a visitor reviews the alternatives table +- **THEN** the page SHALL display explicit comparison criteria +- **AND** each "best for" recommendation SHALL include at least one trade-off statement + +### Requirement: Open-source Intercom route MUST disambiguate product intent +The `/open-source-intercom-alternative/` route SHALL explicitly target customer messaging intent and disambiguate from unrelated intercom hardware/broadcast contexts. + +#### Scenario: Customer messaging intent is explicit +- **WHEN** `/open-source-intercom-alternative/` is rendered +- **THEN** the H1 SHALL include "customer messaging" +- **AND** the intro section SHALL state that the page covers customer support and product messaging software use cases diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-comparison-and-migration-pages/spec.md b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-comparison-and-migration-pages/spec.md new file mode 100644 index 0000000..2c03a8e --- /dev/null +++ b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/intercom-comparison-and-migration-pages/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Landing app MUST provide a deep Intercom comparison route +The landing app SHALL publish `/compare/intercom/` with a structured, source-backed comparison of Opencom and Intercom across core buyer decision dimensions. + +#### Scenario: Comparison dimensions are comprehensive +- **WHEN** `/compare/intercom/` is rendered +- **THEN** it SHALL include comparison rows for feature coverage, deployment models, data ownership/governance, extensibility, and pricing model structure + +#### Scenario: Claims are evidence-backed and status-labeled +- **WHEN** a comparison claim is shown on `/compare/intercom/` +- **THEN** the claim SHALL be backed by a referenced source set used by Opencom's internal competitive analysis +- **AND** capability status SHALL be labeled as "available now" or "roadmap" where parity is incomplete + +### Requirement: Landing app MUST provide a crawlable migration route +The landing app SHALL publish `/migrate-from-intercom/` with practical migration guidance for evaluation-stage and implementation-stage visitors. + +#### Scenario: Migration guide includes execution detail +- **WHEN** `/migrate-from-intercom/` is rendered +- **THEN** it SHALL include phased migration steps covering prerequisite audit, pilot rollout, and full cutover +- **AND** each phase SHALL state expected deliverables and ownership + +#### Scenario: Migration guide includes time and risk framing +- **WHEN** a visitor reads the migration guide +- **THEN** the page SHALL include indicative timeline ranges for common deployment tracks +- **AND** the page SHALL include a risks/gotchas section with mitigation guidance + +### Requirement: Trust pages MUST connect to product and onboarding CTAs +Comparison and migration routes SHALL provide clear next-step paths without forcing navigation back to the homepage. + +#### Scenario: Visitors can continue evaluation immediately +- **WHEN** a visitor reaches the end of `/compare/intercom/` or `/migrate-from-intercom/` +- **THEN** the page SHALL present links to hosted onboarding and implementation documentation +- **AND** the page SHALL include at least one internal link to another Intercom intent-cluster route diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/specs/landing-seo-cluster-linking-and-schema/spec.md b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/landing-seo-cluster-linking-and-schema/spec.md new file mode 100644 index 0000000..d1f4732 --- /dev/null +++ b/openspec/changes/rank-intercom-alternative-intent-cluster/specs/landing-seo-cluster-linking-and-schema/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Intercom SEO cluster MUST maintain a deterministic internal-link graph +Cluster pages SHALL link to each other and to supporting evaluation pages so search engines and visitors can traverse the intent set without orphaned routes. + +#### Scenario: Core cluster routes are cross-linked +- **WHEN** any Intercom cluster page is rendered +- **THEN** the page SHALL include internal links to at least two other cluster pages +- **AND** `/features` SHALL include a prominent entry point into `/compare/intercom/` + +### Requirement: Cluster pages MUST expose unique canonical metadata +Each Intercom cluster route SHALL expose unique title and description metadata aligned to its primary query intent and a canonical URL for that exact route. + +#### Scenario: Metadata is query-aligned per route +- **WHEN** metadata is generated for cluster routes +- **THEN** no two cluster routes SHALL share an identical title string +- **AND** each route SHALL publish a canonical path that matches its own URL + +### Requirement: FAQ schema MUST only represent visible FAQ content +Any FAQ structured data used by cluster pages SHALL mirror visible on-page FAQ questions and answers. + +#### Scenario: FAQ markup parity +- **WHEN** a cluster page includes FAQ JSON-LD +- **THEN** every question and answer in the JSON-LD SHALL be visible in the rendered page content +- **AND** pages without visible FAQ sections SHALL not emit FAQ JSON-LD + +### Requirement: Cluster routes MUST be discoverable for crawling +Intercom cluster routes SHALL be included in crawl discovery artifacts. + +#### Scenario: Sitemap coverage +- **WHEN** landing sitemap output is generated +- **THEN** all cluster routes SHALL be present in sitemap output + +#### Scenario: Robots rules do not block cluster routes +- **WHEN** robots directives are evaluated for production +- **THEN** cluster routes SHALL be indexable and followable by search engines diff --git a/openspec/changes/rank-intercom-alternative-intent-cluster/tasks.md b/openspec/changes/rank-intercom-alternative-intent-cluster/tasks.md new file mode 100644 index 0000000..0c8c4d4 --- /dev/null +++ b/openspec/changes/rank-intercom-alternative-intent-cluster/tasks.md @@ -0,0 +1,30 @@ +## 1. Intent Mapping And Shared Content Models + +- [ ] 1.1 Define route-to-query intent map for `/intercom-alternative/`, `/free-intercom-alternatives/`, `/open-source-intercom-alternative/`, `/compare/intercom/`, and `/migrate-from-intercom/`. +- [ ] 1.2 Add shared content model(s) for comparison rows, alternatives entries, FAQ content, and freshness metadata. +- [ ] 1.3 Seed shared content with source-backed capability/status labels (`available now` vs `roadmap`) using existing competitive analysis inputs. + +## 2. Intent Cluster Route Implementation + +- [ ] 2.1 Implement `/intercom-alternative/` with value proposition, Opencom vs Intercom summary, and conversion CTAs. +- [ ] 2.2 Implement `/open-source-intercom-alternative/` with explicit customer-messaging disambiguation and FAQ block. +- [ ] 2.3 Implement `/free-intercom-alternatives/` with at least eight alternatives, visible methodology, and trade-off statements. + +## 3. Trust Route Implementation + +- [ ] 3.1 Implement `/compare/intercom/` with feature/deployment/pricing/governance comparison matrix. +- [ ] 3.2 Implement `/migrate-from-intercom/` with phased migration plan, timeline ranges, risks, and mitigations. +- [ ] 3.3 Add strong next-step CTAs and reciprocal internal links across all cluster pages. + +## 4. SEO Infrastructure And Linking + +- [ ] 4.1 Extend metadata helpers for route-unique titles, descriptions, and canonical output. +- [ ] 4.2 Add FAQ and breadcrumb structured data helpers, ensuring JSON-LD content mirrors visible page content. +- [ ] 4.3 Add or update sitemap/robots outputs so all cluster routes are crawl-discoverable and indexable. +- [ ] 4.4 Update `/features`, navbar, and footer surfaces to include intentional entry points into the comparison cluster. + +## 5. Verification + +- [ ] 5.1 Verify each new route renders successfully with expected H1 intent and internal-link graph. +- [ ] 5.2 Validate metadata and structured data output for parity and correctness. +- [ ] 5.3 Run landing package quality checks (typecheck/lint/tests as available) and resolve regressions. diff --git a/scripts/export-landing-content-to-md.ts b/scripts/export-landing-content-to-md.ts new file mode 100644 index 0000000..a4c1001 --- /dev/null +++ b/scripts/export-landing-content-to-md.ts @@ -0,0 +1,422 @@ +#!/usr/bin/env tsx + +import { chromium, type Page } from "@playwright/test"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +const DEFAULT_BASE_URL = process.env.LANDING_BASE_URL ?? "http://127.0.0.1:4000"; +const DEFAULT_OUT_DIR = process.env.LANDING_MD_OUT_DIR ?? "artifacts/landing-help-center-md"; +const LANDING_APP_SRC_DIR = path.resolve(process.cwd(), "apps", "landing", "src", "app"); +const LANDING_MD_EXTRACTOR_PATH = path.resolve( + process.cwd(), + "scripts", + "lib", + "landing-md-extractor.runtime.js" +); +const SERVER_READY_TIMEOUT_MS = 120_000; +const NAVIGATION_TIMEOUT_MS = 60_000; +const LANDING_MD_EXTRACTOR_SCRIPT = readFileSync(LANDING_MD_EXTRACTOR_PATH, "utf8"); + +type CliOptions = { + baseUrl: string; + outDir: string; + noStartServer: boolean; + routeFilters: string[]; +}; + +type ExportRecord = { + route: string; + file: string; + title: string; + url: string; +}; + +type ExtractedPage = { + title: string; + markdown: string; +}; + +function printHelp() { + console.log(`Export landing site content to Markdown files. + +Usage: + pnpm export:landing:md + pnpm export:landing:md --base-url http://127.0.0.1:4000 + pnpm export:landing:md --routes /privacy,/roadmap + pnpm export:landing:md --out-dir artifacts/my-help-center-md + +Options: + --base-url Base URL to crawl (default: ${DEFAULT_BASE_URL}) + --out-dir Output directory for markdown files (default: ${DEFAULT_OUT_DIR}) + --routes Comma-separated routes to export (default: all landing routes) + --no-start-server Do not auto-start apps/landing dev server + -h, --help Show this help +`); +} + +function normalizeRoute(value: string): string { + const trimmed = value.trim(); + if (!trimmed || trimmed === "/") { + return "/"; + } + const normalized = trimmed.replace(/^\/+|\/+$/g, ""); + return `/${normalized}`; +} + +function sanitizeFileName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +function routeToFileName(route: string): string { + if (route === "/") { + return "home.md"; + } + const base = sanitizeFileName(route.slice(1).replace(/\//g, "-")); + return `${base || "page"}.md`; +} + +function normalizeBaseUrl(value: string): string { + const parsed = new URL(value); + if (parsed.pathname === "/") { + parsed.pathname = ""; + } + return parsed.toString().replace(/\/$/, ""); +} + +function parseCliArgs(argv: string[]): CliOptions | null { + const options: CliOptions = { + baseUrl: DEFAULT_BASE_URL, + outDir: path.resolve(process.cwd(), DEFAULT_OUT_DIR), + noStartServer: false, + routeFilters: [], + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "-h" || arg === "--help") { + printHelp(); + return null; + } + + if (arg === "--no-start-server") { + options.noStartServer = true; + continue; + } + + if (arg === "--base-url" || arg === "--out-dir" || arg === "--routes") { + const nextValue = argv[index + 1]; + if (!nextValue) { + throw new Error(`Missing value for ${arg}`); + } + index += 1; + + if (arg === "--base-url") { + options.baseUrl = nextValue; + } else if (arg === "--out-dir") { + options.outDir = path.resolve(process.cwd(), nextValue); + } else { + options.routeFilters = nextValue + .split(",") + .map((item) => normalizeRoute(item)) + .filter((item, itemIndex, arr) => arr.indexOf(item) === itemIndex); + } + continue; + } + + throw new Error(`Unknown argument: ${arg}`); + } + + options.baseUrl = normalizeBaseUrl(options.baseUrl); + return options; +} + +async function collectLandingRoutes(dir: string, prefix = ""): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const routes: string[] = []; + + for (const entry of entries) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + const nestedPrefix = prefix ? path.join(prefix, entry.name) : entry.name; + routes.push(...(await collectLandingRoutes(entryPath, nestedPrefix))); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const isPageFile = /^page\.(tsx|ts|jsx|js|mdx)$/.test(entry.name); + if (!isPageFile) { + continue; + } + + const route = prefix ? `/${prefix.split(path.sep).join("/")}` : "/"; + routes.push(normalizeRoute(route)); + } + + return routes; +} + +async function isServerReachable(baseUrl: string): Promise { + try { + const response = await fetch(baseUrl, { + method: "GET", + }); + return response.ok; + } catch { + return false; + } +} + +function startLandingDevServer(): ChildProcessWithoutNullStreams { + const child = spawn("pnpm", ["--filter", "@opencom/landing", "dev"], { + cwd: process.cwd(), + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => { + process.stdout.write(`[landing-dev] ${String(chunk)}`); + }); + child.stderr.on("data", (chunk) => { + process.stderr.write(`[landing-dev] ${String(chunk)}`); + }); + + return child; +} + +async function waitForServer( + baseUrl: string, + timeoutMs: number, + serverProcess?: ChildProcessWithoutNullStreams +): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await isServerReachable(baseUrl)) { + return; + } + + if (serverProcess && serverProcess.exitCode !== null) { + throw new Error(`Landing dev server exited early with code ${serverProcess.exitCode}.`); + } + + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error(`Timed out waiting for landing site at ${baseUrl}`); +} + +async function stopProcess(serverProcess?: ChildProcessWithoutNullStreams): Promise { + if (!serverProcess || serverProcess.exitCode !== null) { + return; + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (serverProcess.exitCode === null) { + serverProcess.kill("SIGKILL"); + } + resolve(); + }, 5_000); + + serverProcess.once("exit", () => { + clearTimeout(timeout); + resolve(); + }); + + serverProcess.kill("SIGTERM"); + }); +} + +function ensureTopHeading(markdown: string, title: string): string { + const trimmed = markdown.trim(); + if (!trimmed) { + return `# ${title}\n`; + } + if (/^#\s+/m.test(trimmed)) { + return `${trimmed}\n`; + } + return `# ${title}\n\n${trimmed}\n`; +} + +function cleanExtractedMarkdown(markdown: string, route: string): string { + let lines = markdown.split("\n"); + + if (route === "/") { + lines = lines.filter((line) => { + const trimmed = line.trim(); + if (!trimmed) { + return true; + } + if (/^\d+$/.test(trimmed)) { + return false; + } + if (/^\d+[smhd] ago$/i.test(trimmed)) { + return false; + } + return true; + }); + + const startIndex = lines.findIndex((line) => line.trim() === "Live Inbox"); + if (startIndex !== -1) { + const endIndex = lines.findIndex( + (line, index) => index > startIndex && line.trim().startsWith("Ask AI Agent:") + ); + if (endIndex !== -1) { + lines.splice(startIndex, endIndex - startIndex + 1); + } + } + } + + const firstHeadingIndex = lines.findIndex((line) => /^#\s+/.test(line.trim())); + if (firstHeadingIndex > 0) { + const prefix = lines.slice(0, firstHeadingIndex).filter((line) => line.trim().length > 0); + const shouldDropPrefix = + prefix.length > 0 && prefix.length <= 2 && prefix.every((line) => line.trim().length <= 50); + if (shouldDropPrefix) { + lines = lines.slice(firstHeadingIndex); + } + } + + return lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +async function extractMarkdownFromPage(page: Page): Promise { + const extracted = await page.evaluate( + (script) => window.eval(script), + LANDING_MD_EXTRACTOR_SCRIPT + ); + return extracted as ExtractedPage; +} + +async function main() { + const options = parseCliArgs(process.argv.slice(2)); + if (!options) { + return; + } + + if (!existsSync(LANDING_APP_SRC_DIR)) { + throw new Error(`Landing app directory not found: ${LANDING_APP_SRC_DIR}`); + } + + const discoveredRoutes = (await collectLandingRoutes(LANDING_APP_SRC_DIR)).sort((a, b) => { + if (a === "/") { + return -1; + } + if (b === "/") { + return 1; + } + return a.localeCompare(b); + }); + + if (discoveredRoutes.length === 0) { + throw new Error(`No landing routes found under ${LANDING_APP_SRC_DIR}`); + } + + const selectedRoutes = + options.routeFilters.length > 0 + ? discoveredRoutes.filter((route) => options.routeFilters.includes(route)) + : discoveredRoutes; + + if (selectedRoutes.length === 0) { + throw new Error( + `No routes matched filters: ${options.routeFilters.join(", ")}. Available routes: ${discoveredRoutes.join(", ")}` + ); + } + + for (const routeFilter of options.routeFilters) { + if (!discoveredRoutes.includes(routeFilter)) { + console.warn(`Skipping unknown route filter: ${routeFilter}`); + } + } + + let serverProcess: ChildProcessWithoutNullStreams | undefined; + const records: ExportRecord[] = []; + + try { + const reachable = await isServerReachable(options.baseUrl); + if (!reachable) { + if (options.noStartServer) { + throw new Error( + `Landing site is not reachable at ${options.baseUrl}. Start it with "pnpm --filter @opencom/landing dev" or remove --no-start-server.` + ); + } + + console.log(`Landing site not reachable at ${options.baseUrl}. Starting local dev server...`); + serverProcess = startLandingDevServer(); + await waitForServer(options.baseUrl, SERVER_READY_TIMEOUT_MS, serverProcess); + } + + await fs.mkdir(options.outDir, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext(); + for (const route of selectedRoutes) { + const url = new URL(route, `${options.baseUrl}/`).toString(); + const page = await context.newPage(); + console.log(`Exporting ${route} -> ${url}`); + + await page.goto(url, { waitUntil: "networkidle", timeout: NAVIGATION_TIMEOUT_MS }); + await page.waitForSelector("main", { timeout: 15_000 }); + + const extracted = await extractMarkdownFromPage(page); + const fileName = routeToFileName(route); + const targetFile = path.join(options.outDir, fileName); + const cleanedMarkdown = cleanExtractedMarkdown(extracted.markdown, route); + const markdown = ensureTopHeading(cleanedMarkdown, extracted.title); + + await fs.writeFile(targetFile, markdown, "utf8"); + records.push({ + route, + file: fileName, + title: extracted.title, + url, + }); + + await page.close(); + } + + await context.close(); + } finally { + await browser.close(); + } + + const manifestPath = path.join(options.outDir, "manifest.json"); + await fs.writeFile( + manifestPath, + `${JSON.stringify( + { + exportedAt: new Date().toISOString(), + baseUrl: options.baseUrl, + count: records.length, + pages: records, + }, + null, + 2 + )}\n`, + "utf8" + ); + + console.log(`\nExport complete.`); + console.log(`Markdown files: ${options.outDir}`); + console.log(`Manifest: ${manifestPath}`); + } finally { + await stopProcess(serverProcess); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/lib/landing-md-extractor.runtime.js b/scripts/lib/landing-md-extractor.runtime.js new file mode 100644 index 0000000..279f0e8 --- /dev/null +++ b/scripts/lib/landing-md-extractor.runtime.js @@ -0,0 +1,274 @@ +(() => { + const root = document.querySelector("main"); + + function normalizeWhitespace(value) { + return value.replace(/\s+/g, " ").trim(); + } + + function collapseInline(value) { + return normalizeWhitespace(value).replace(/\s+([,.;:!?])/g, "$1"); + } + + function resolveHref(value) { + if (!value) { + return ""; + } + if (value.startsWith("http://") || value.startsWith("https://")) { + return value; + } + if (value.startsWith("mailto:") || value.startsWith("tel:")) { + return value; + } + if (value.startsWith("#")) { + return value; + } + if (value.startsWith("/")) { + return value; + } + return value; + } + + const ignoredTags = new Set([ + "script", + "style", + "noscript", + "svg", + "canvas", + "iframe", + "video", + "audio", + "picture", + "img", + "source", + ]); + + function serializeInline(node) { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent || ""; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const element = node; + const tag = element.tagName.toLowerCase(); + + if (ignoredTags.has(tag) || element.getAttribute("aria-hidden") === "true") { + return ""; + } + + if (tag === "br") { + return "\n"; + } + + if (tag === "a") { + const href = resolveHref((element.getAttribute("href") || "").trim()); + const label = collapseInline( + Array.from(element.childNodes) + .map((child) => serializeInline(child)) + .join(" ") + ); + const linkLabel = label || href; + if (!href || !linkLabel) { + return ""; + } + return `[${linkLabel}](${href})`; + } + + if (tag === "strong" || tag === "b") { + const content = collapseInline( + Array.from(element.childNodes) + .map((child) => serializeInline(child)) + .join(" ") + ); + return content ? `**${content}**` : ""; + } + + if (tag === "em" || tag === "i") { + const content = collapseInline( + Array.from(element.childNodes) + .map((child) => serializeInline(child)) + .join(" ") + ); + return content ? `*${content}*` : ""; + } + + if ( + tag === "code" && + (!element.parentElement || element.parentElement.tagName.toLowerCase() !== "pre") + ) { + const content = normalizeWhitespace(element.textContent || ""); + return content ? `\`${content}\`` : ""; + } + + return Array.from(element.childNodes) + .map((child) => serializeInline(child)) + .join(" "); + } + + function serializeList(listElement, ordered) { + const items = Array.from(listElement.children).filter( + (child) => child.tagName.toLowerCase() === "li" + ); + + const lines = []; + items.forEach((item, index) => { + const primaryText = collapseInline( + Array.from(item.childNodes) + .filter((child) => { + if (child.nodeType !== Node.ELEMENT_NODE) { + return true; + } + const tag = child.tagName.toLowerCase(); + return tag !== "ul" && tag !== "ol"; + }) + .map((child) => serializeInline(child)) + .join(" ") + ); + + const marker = ordered ? `${index + 1}.` : "-"; + if (primaryText) { + lines.push(`${marker} ${primaryText}`); + } + + const nestedLists = Array.from(item.children).filter((child) => { + const tag = child.tagName.toLowerCase(); + return tag === "ul" || tag === "ol"; + }); + nestedLists.forEach((nestedList) => { + const nestedMarkdown = serializeList(nestedList, nestedList.tagName.toLowerCase() === "ol"); + if (!nestedMarkdown) { + return; + } + const indented = nestedMarkdown + .split("\n") + .map((line) => (line ? ` ${line}` : line)) + .join("\n"); + lines.push(indented); + }); + }); + + return lines.join("\n"); + } + + function serializeBlock(node) { + if (node.nodeType === Node.TEXT_NODE) { + const text = collapseInline(node.textContent || ""); + return text ? `${text}\n\n` : ""; + } + + if (node.nodeType !== Node.ELEMENT_NODE) { + return ""; + } + + const element = node; + const tag = element.tagName.toLowerCase(); + + if (ignoredTags.has(tag) || element.getAttribute("aria-hidden") === "true") { + return ""; + } + + if ( + tag === "h1" || + tag === "h2" || + tag === "h3" || + tag === "h4" || + tag === "h5" || + tag === "h6" + ) { + const level = Number.parseInt(tag.slice(1), 10); + const headingText = collapseInline( + Array.from(element.childNodes) + .map((child) => serializeInline(child)) + .join(" ") + ); + return headingText ? `${"#".repeat(level)} ${headingText}\n\n` : ""; + } + + if (tag === "p") { + const paragraph = collapseInline( + Array.from(element.childNodes) + .map((child) => serializeInline(child)) + .join(" ") + ); + return paragraph ? `${paragraph}\n\n` : ""; + } + + if (tag === "ul" || tag === "ol") { + const listMarkdown = serializeList(element, tag === "ol"); + return listMarkdown ? `${listMarkdown}\n\n` : ""; + } + + if (tag === "pre") { + const code = (element.textContent || "") + .replace(/\u00a0/g, " ") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return code ? `\`\`\`\n${code}\n\`\`\`\n\n` : ""; + } + + if (tag === "a") { + const link = collapseInline(serializeInline(element)); + return link ? `${link}\n\n` : ""; + } + + if (tag === "hr") { + return "---\n\n"; + } + + return Array.from(element.childNodes) + .map((child) => serializeBlock(child)) + .join(""); + } + + if (!root) { + const fallbackTitle = + document.title.replace(/\s*\|\s*Opencom.*$/i, "").trim() || "Landing Page"; + return { + title: fallbackTitle, + markdown: `# ${fallbackTitle}`, + }; + } + + const clonedRoot = root.cloneNode(true); + const removableSelectors = [ + "script", + "style", + "noscript", + "svg", + "canvas", + "iframe", + "video", + "audio", + "picture", + "img", + "source", + "[aria-hidden='true']", + "[hidden]", + "template", + ]; + removableSelectors.forEach((selector) => { + clonedRoot.querySelectorAll(selector).forEach((element) => element.remove()); + }); + + let markdown = Array.from(clonedRoot.childNodes) + .map((child) => serializeBlock(child)) + .join(""); + + markdown = markdown + .replace(/\r\n/g, "\n") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + + const titleFromHeading = normalizeWhitespace( + (clonedRoot.querySelector("h1") || {}).textContent || "" + ); + const titleFromDocument = normalizeWhitespace(document.title.replace(/\s*\|\s*Opencom.*$/i, "")); + const title = titleFromHeading || titleFromDocument || "Landing Page"; + + return { + title, + markdown, + }; +})();