> ## Documentation Index
> Fetch the complete documentation index at: https://launchdarkly.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 52 Blog Posts, Claude, 3 Prompts, Under an Hour

export const BlogAuthor = ({date, avatar, alt, name}) => {
  return <div className="-mb-4">
      <p className="m-0 text-base font-semibold">
        <em>Published {date}</em>
      </p>
      <div className="flex items-center -mt-4">
        {avatar && <img src={avatar} alt={alt} className="h-8 w-8 rounded-full m-0" />}
        <p className="ml-[5px] text-lg">{name}</p>
      </div>
    </div>;
};

<View title="Developer" />

<View title="Federal docs" />

<View title="EU docs" />

<BlogAuthor date="March 10, 2026" avatar="/images/authors/alexis-roberson.png" alt="portrait of Alexis Roberson." name="by Alexis Roberson" />

So, how did I turn a week-long blog migration into a one-hour task? The short answer is Claude and also providing prompts containing more details than a flight safety manual.

When LaunchDarkly acquired Highlight in March 2025, one of the first tasks on the integration roadmap was clear on paper: migrate Highlight's developer-focused blog content into the LaunchDarkly documentation site. Highlight had accumulated over 100 blog posts covering observability, OpenTelemetry, session replay, ClickHouse optimization, and more. The kind of deep technical content that aligned naturally with the LaunchDarkly tutorials page.

The reality was more nuanced. Not every blog was being transferred. The source files were in Markdown, but LaunchDarkly's docs are powered by [Fern](https://buildwithfern.com/) and require `.mdx` with specific frontmatter conventions. The blogs needed to be integrated in chronological order alongside existing tutorials. Author images needed to follow a naming convention. Brand references and URLs had to be updated. And all of this needed to be done carefully — on a production documentation repository that serves real users.

I completed the entire migration in under an hour. Here's how.

## The scope: 52 blogs, two destinations

The observability team identified the blogs to migrate using a shared Excel spreadsheet, marking each approved post with a checkmark. Out of 107 total posts in Highlight's `blog-content` repository, 52 made the cut.

These 52 posts were split between two destinations on LaunchDarkly's site:

* **21 posts** went to the [Tutorials page](/docs/tutorials) — practical guides on topics like OpenTelemetry instrumentation, distributed tracing, and monitoring frameworks
* **31 posts** went to the [Flagship blog](/docs/blog) — engineering deep dives on ClickHouse optimization, session replay benchmarking, error grouping, and infrastructure work

The split was intentional. Tutorial-style content like "The Complete Guide to OpenTelemetry in Python" or "How to Instrument Your React Native App with OpenTelemetry" belonged in tutorials. Engineering narratives like "Migrating from OpenSearch to ClickHouse" or "Building GitHub Enhanced Stacktraces" fit the blog's voice better.

## Step 1: Filter and stage the approved blogs

The first step was extracting only the approved posts from Highlight's full blog repository. I downloaded the Excel sheet containing the approval checkmarks and the `blog-content` directory from Highlight's GitHub repo. My initial prompt to Claude laid out the filtering criteria:

> *Clone launchdarkly/observability/blog-content and highlight/highlight/blog-content and compare them to make sure they're matching. If they are matching, filter the blogs that contain "blogs" in the path and that do not contain "vs" or "VS" or "Vs" in the blog title. For every blog left over, convert the .md file to .mdx to match fern standards, keep the dates the same. Once converted, migrate every .mdx file into the ld-docs-private repo under tutorials and ensure chronological order by referencing the date in the file.*

This first prompt established the end-to-end vision. From there, I broke the execution into discrete steps, starting with creating a new `blogs-to-migrate/blog-content` directory containing only the 52 approved posts.

This gave me a clean working directory with exactly the files I needed, separated from the full 107-post source repository.

## Step 2: Convert .md to .mdx with a single prompt

LaunchDarkly's Fern documentation requires `.mdx` files with a specific frontmatter structure that looks nothing like Highlight's original format. Here's what a Highlight blog's frontmatter looked like:

```yaml lines wrap theme={null}
title: "What is Frontend Monitoring and What Tools Help You Do It?"
createdAt: 2022-08-23T12:00:00Z
readingTime: 8
authorFirstName: "Haroon"
authorLastName: "Choudery"
authorTitle: "Software Engineer"
tags: "Frontend, Observability"
```

And here's what Fern needed:

```yaml lines wrap theme={null}
slug: /tutorials/what-is-frontend-monitoring
title: "What is Frontend Monitoring and What Tools Help You Do It?"
og:title: "What is Frontend Monitoring and What Tools Help You Do It?"
description: "For frontend developers, building cool apps for the web is fun."
og:description: "For frontend developers, building cool apps for the web is fun."
keywords: frontend, observability, monitoring, tools
```

Plus a structured author block with image references, a formatted publish date, and content transformations like wrapping images in `<Frame>` components and converting internal blog links to relative `/tutorials/` paths.

I provided Claude with a single example of the target `.mdx` format and a detailed prompt that covered every transformation rule in one shot. Here's the prompt I used:

> *Now here comes the fun part. I'm converting all the files in blogs-to-migrate/blog-content into .mdx files for fern documentation. Can you use this example blog structure as a reference:*
>
> ```
> ---
> slug: /tutorials/detection-to-resolution-rage-clicks
> title: "Detection to Resolution: Real world debugging with rage clicks and session replay"
> og:title: "Detection to Resolution: Real world debugging with rage clicks and session replay"
> description: "Learn how to use session replay and rage click detection to diagnose and fix production issues fast through three real-world case studies."
> og:description: "Learn how to use session replay and rage click detection to diagnose and fix production issues fast through three real-world case studies."
> keywords: monitoring, observability, rage clicks, debugging
> ---
>
> <p className="publishedDate"><em>Published February 11, 2026</em></p>
> <div className="authorWrapper">
>   <img
>     src="/images/authors/alexis-roberson.png"
>     alt="portrait of Alexis Roberson."
>     className="authorAvatar"
>   />
>   <p className="authorName">by Alexis Roberson</p>
> </div>
> ```
>
> *For each blog, the slug should be set to tutorials/, for example, for the blog whose file name is what-is-frontend-monitoring, the slug should be /tutorials/what-is-frontend-monitoring. The title can be the original title field in the .md file. Don't forget to set a og:title field with the same title for each new .mdx file. For the description and og:description field, you will need to generate a one sentence description for each blog, and you will also need to generate 4 keywords for each blog post that are related to the blog. For example keywords: monitoring, observability, rage clicks, debugging. For the published date make sure its in this format Month Date, Year. So like this February 11, 2026. For the div img src tag, the name of the .png file should be the first and last name of the author. So if the person's name is Alexis Roberson, the src should be src="/images/authors/alexis-roberson.png" and the div img alt should be set to for example portrait of Alexis Roberson.*

Notice how much detail is packed into this single prompt — the example template, the slug convention, the date format, the author image path pattern, and instructions for generating descriptions and keywords. This level of specificity is what allowed Claude to generate a complete, correct script on the first pass.

Claude generated `convert_blogs.py` — a 277-line Python script that handled all 52 conversions in one execution. The script:

* **Parsed YAML frontmatter** including multi-line values and various quoting styles
* **Generated descriptions** by extracting the first meaningful paragraph, stripping markdown formatting, and capping at 160 characters
* **Extracted keywords** by combining the original tags with significant title words (filtering out stop words), ensuring exactly four keywords per post
* **Formatted dates** from ISO 8601 to "Month Day, Year" format
* **Transformed body content** by removing `<BlogCallToAction />` components, wrapping standalone images in `<Frame>` tags, and converting internal Highlight blog links to relative `/tutorials/` paths

Here's the `transform_body()` function that handled the content transformations — the most nuanced part of the conversion:

````python expandable lines wrap theme={null}
def transform_body(body):
    """Apply body content transformations."""
    lines = body.split("\n")
    result = []
    in_code_block = False
    i = 0

    while i < len(lines):
        line = lines[i]

        # Track code blocks to avoid transforming inside them
        if line.strip().startswith("```"):
            in_code_block = not in_code_block
            result.append(line)
            i += 1
            continue

        if in_code_block:
            result.append(line)
            i += 1
            continue

        # Remove <BlogCallToAction /> lines (and surrounding blank lines)
        if re.match(r'^\s*<BlogCallToAction\s*/?\s*>\s*$', line):
            if i + 1 < len(lines) and lines[i + 1].strip() == "":
                i += 2
            else:
                i += 1
            if result and result[-1].strip() == "":
                result.pop()
            continue

        # Wrap standalone images in <Frame> tags
        img_match = re.match(r'^(\s*)(!\[.*\]\(.*\))\s*$', line)
        if img_match:
            indent = img_match.group(1)
            img_tag = img_match.group(2)
            result.append(f"{indent}<Frame>")
            result.append(f"{indent}  {img_tag}")
            result.append(f"{indent}</Frame>")
            i += 1
            continue

        # Update internal blog links
        line = re.sub(
            r'https://www\.highlight\.io/blog/([a-zA-Z0-9_-]+)',
            r'/tutorials/\1',
            line
        )

        result.append(line)
        i += 1

    return "\n".join(result)
````

This function walks through every line of the blog body, skipping code blocks, stripping Highlight-specific call-to-action components, wrapping images in Fern's `<Frame>` component, and rewriting internal blog links — all in a single pass.

One execution. 52 files converted. Zero manual edits needed.

## Step 3: Rebrand content while preserving code integrity

The converted blogs still referenced "Highlight" throughout their content. A naive find-and-replace would have been destructive — these are technical posts filled with code blocks referencing `HighlightInit`, API endpoints like `otel.highlight.io`, and package imports. Replacing those would break every code example.

Claude generated `replace_brand.py`, a script with a surgical approach to brand replacement. It used regex-based protection to identify and preserve:

* **Code blocks** (anything between \`\`\` delimiters)
* **Inline code** (backtick-wrapped text)
* **URLs** (bare URLs and markdown link targets)
* **HTML attributes** (src and href values)

Only prose text outside these protected segments got the brand swap. Here's the core of that approach:

```python expandable lines wrap theme={null}
# Regex that matches segments to PROTECT from brand replacement
PROTECTED = re.compile(r"""
    `[^`]+`                          # inline code
    | \]\([^)]+\)                    # markdown link URL ](url)
    | (?:src|href)="[^"]*"           # HTML src/href attributes
    | https?://\S+                   # bare URLs
""", re.VERBOSE)

def replace_brand_in_segment(text):
    """Replace Highlight brand references in an unprotected text segment."""
    text = re.sub(r'(?<!\.)Highlight\.io', 'LaunchDarkly', text)
    text = re.sub(r'(?<!\.)highlight\.io', 'LaunchDarkly', text)
    text = re.sub(r'\bHighlight\b', 'LaunchDarkly', text)
    return text

def process_line(line):
    """Process a single line, protecting URLs/code/paths."""
    parts = []
    last_end = 0
    # cspell:disable-next-line
    for match in PROTECTED.finditer(line):
        # Unprotected text before this match — apply brand replacement
        parts.append(replace_brand_in_segment(line[last_end:match.start()]))
        # Protected match — keep as-is
        parts.append(match.group())
        last_end = match.end()
    parts.append(replace_brand_in_segment(line[last_end:]))
    return ''.join(parts)
```

The `PROTECTED` regex identifies every segment that should be left alone — inline code, link URLs, HTML attributes, and bare URLs. The `process_line()` function splits each line around those protected segments, only applying the brand swap to the gaps in between. The `\b` word boundary in `replace_brand_in_segment()` ensures compound identifiers like `HighlightInit` or `AppRouterHighlight` are never touched. The script processed all 52 files, updating only the lines that needed changes.

A follow-up script, `update_urls.py`, handled the URL layer — converting `highlight.io/docs/` to `launchdarkly.com/docs/`, `app.highlight.io` to `app.launchdarkly.com`, and catching any remaining blog cross-references. It even handled edge cases like brand references inside ` ```hint ` blocks (which the previous script had conservatively skipped as code) and `_Highlight_` markdown italic patterns.

## Step 4: Transfer to the Fern repo in chronological order

With 52 properly formatted and rebranded `.mdx` files ready, the next step was integrating them into LaunchDarkly's documentation repository. This wasn't just copying files — each blog needed its own directory, a YAML config entry, and chronological ordering. Here's the prompt I used:

> *I copied over the fern directory project called ld-docs-private to practice before the official migration. I want you to transfer all the .mdx files from blogs-to-migrate/blog-content and store them in ld-docs-private/fern/topics/tutorials, creating a folder for each blog and storing it in that folder. For example, if the blog .mdx file is what-is-feature-monitoring.mdx, then its path would be ld-docs-private/fern/topics/tutorials/what-is-feature-monitoring/what-is-feature-monitoring.mdx. Inside ld-docs-private/fern/topics/versions/ld-docs.yml file the blog title and the blog file folder and path created in the ld-docs-private/fern/topics/tutorials directory need to be added under tab:tutorials -> section: Tutorials -> contents where page is the title and path is the path to find the blog's .mdx file. But here's the catch — the blogs I'm migrating over to ld-docs-private need to be integrated in chronological order in respect to the existing blog order inside ld-docs.yml file based on the blog's publish date.*

This prompt specified three things at once: the directory structure convention, the YAML config format, and the chronological ordering constraint. That last requirement — "in respect to the existing blog order" — was the trickiest part.

Claude generated `transfer_blogs.py` to handle this. The heart of the script was the chronological merge — combining existing tutorials with the new Highlight posts and sorting them by publish date:

```python expandable lines wrap theme={null}
def main():
    # Parse existing tutorial entries from ld-docs.yml
    existing = get_existing_entries()

    # Collect new blog entries with their dates and titles
    new_blogs = get_new_blog_entries()

    # Create folder structure and copy .mdx files
    create_tutorial_folders_and_copy(new_blogs)

    # Merge all entries and sort by date, newest first
    all_entries = existing + new_blogs
    epoch = datetime(1970, 1, 1)
    all_entries.sort(key=lambda e: e['date'] or epoch, reverse=True)

    # Rewrite the tutorials section in ld-docs.yml
    update_yml(all_entries)
```

The chronological ordering was critical. Most of the Highlight blogs were published between 2022 and 2024 — older than the latest LaunchDarkly tutorials. Simply appending them would have buried them at the bottom. The sort ensured they appeared at the correct position in the timeline, interleaved with existing content based on their original publish dates.

## Step 5: Author images and asset management

Every blog post referenced an author portrait image following the `firstname-lastname.png` convention. I needed headshots for all the Highlight authors whose posts were being migrated. After collecting the images, I ran one more prompt:

> *Now can you take the authors from every blog I migrated over and add a png file for it with the name of the .png file being firstname-lastname.png, for instance alexis-roberson.png. Also make sure the name doesn't already exist in the directory. The directory path is ld-docs-private/fern/assets/images/authors.*

This ensured every author referenced in the `.mdx` files had a corresponding image in the right location, with no naming conflicts against existing author images.

The blog posts also referenced inline images stored under topic-specific directories in Fern's assets. These image files and their directory structures needed to be copied over to ensure nothing rendered as a broken link.

## The test-first approach

Before touching the production repository, I tested the entire workflow on a copy. I cloned the `ld-docs-private` repo into a local test directory and ran all four scripts against it. This let me verify that:

* Every `.mdx` file rendered correctly with valid frontmatter
* The `ld-docs.yml` configuration was valid YAML with proper indentation
* Chronological ordering was correct
* No brand references leaked through in code blocks
* All internal links pointed to valid tutorial slugs
* Author image paths resolved correctly

Only after confirming the test migration was clean did I run the same prompts against the real Fern repository.

## What made this work

Looking back, the key to completing this in under an hour came down to three things:

**Detailed first prompts.** Each prompt I wrote specified every transformation rule, edge case, and output format upfront. I included a concrete example of the target `.mdx` structure, spelled out the slug derivation logic, the date format, the author image path convention, and the chronological ordering requirement. This front-loaded effort meant Claude could generate complete, correct scripts on the first pass instead of requiring iterative debugging.

**Letting AI handle the tedious parts.** Generating 52 unique descriptions from first paragraphs, extracting keywords, reformatting dates, handling YAML quoting edge cases, protecting code blocks during brand replacement — these are exactly the kinds of repetitive, detail-oriented tasks where manual work introduces errors and AI excels.

**Testing on a copy first.** Running the full pipeline on a cloned repo before touching production meant I could refine my prompts with zero risk. The few adjustments I made — like handling \`\`\`\`hint\` blocks and ordinal date suffixes — were caught during testing, not after deployment.

## The numbers

| Metric | Count |
| - | - |
| Total blogs migrated | 52 |
| Posts to Tutorials page | 21 |
| Posts to Flagship blog | 31 |
| Python scripts generated | 4 |
| Total lines of automation code | \~650 |
| Manual file edits required | 0 |
| Time to complete | \< 1 hour |

## Conclusion

Content migration is one of those tasks that looks simple until you start accounting for format differences, ordering constraints, brand consistency, and asset management. What could have been days of manual copy-paste-edit work became a streamlined, automated pipeline — powered by a few well-crafted prompts and the scripts they produced.

The Highlight blog content now lives on LaunchDarkly's [Tutorials](/docs/tutorials) and [Blog](/docs/blog) pages, interleaved chronologically with existing content, properly branded, and fully integrated into the Fern documentation framework. If you're facing a similar migration challenge, the approach is repeatable: define your target format precisely, test on a copy, and let automation handle the volume.
