Andrew Harry

andrewharry.com

Adding a dynamic Blog Outline

Using Copilot AI

Adding a dynamic ‘blog outline’ with Svelte and AI

Today I am going to use AI’s help with adding a type of ‘table of contents’ side bar to each of my blog posts.
This ‘blog outline’ is going to automatically read the post’s content for headers and then list them out on the right hand side.

This is ideal for the current state of AI:

  • Clear requirements.
  • Easily Verified and Tested.
  • Should save me time.

Now, you are reading this after I have added the blog outline.

To see what it was like before…

Hide Table of Contents

NOTE: This is not being shown on mobile devices!

Prompt Crafting

This shouldn’t be too hard because I know exactly what I want the outputs to be. I have seen this same concept in a few places.

The goal is to enrich my longer blog posts with a sticky ‘table of content’ short list over on the right hand side.

Stoplight.io does this well - see example here.


Version 1.0

Let’s start with something simple and build up from there.

Here’s my first prompt:

Write a svelte component which reads the inner html content from a given element on the current page and creates a 'table of contents' from all of the headers it finds.
- The 'table of contents' should be rendered as a html bullet list.    
- It should create dynamic anchor links for each html header it finds.
- each entry in the 'table of contents' should be a link to the corresponding header on the page.
- use typescript and svelte to create the component.
<script lang="ts">
  import { onMount } from 'svelte';

  let tableOfContents: string[] = [];

  onMount(() => {
    const element = document.getElementById('content');
    if (element) {
      const headers = element.querySelectorAll('h1, h2, h3, h4, h5, h6');
      headers.forEach((header, index) => {
        const id = `toc-${index}`;
        header.id = id;
        tableOfContents.push({ id, text: header.textContent });
      });
    }
  });
</script>

<style>
  .toc {
    list-style-type: disc;
    margin-left: 20px;
  }
</style>

<ul>
  {#each tableOfContents as tocItem (tocItem.id)}
    <li class="toc"><a href="#{tocItem.id}">{tocItem.text}</a></li>
  {/each}
</ul>

Add first glance, this looks pretty close and certainly worth trying out. It has correctly used svelte and typescript, it looks like it is scanning the content element for any headers.

There is a minor issue which I am going to manually fix.

+ let tableOfContents: any[] = []; 
- let tableOfContents: string[] = []; 

How’d it go?

It definitely did something to the html elements, but I don’t see any li elements added to the new ul class it added.

table of contents - attempt #1

There might be something wrong with the following code?

<ul>
  {#each tableOfContents as tocItem (tocItem.id)}
    <li class="toc"><a href="#{tocItem.id}">{tocItem.text}</a></li>
  {/each}
</ul>

Version 1.1

Using the same prompt, I tried again to see what Copilot returns this time.

<script lang="ts">
    import { onMount } from 'svelte';
  
    let headers = [];
  
    onMount(() => {
      headers = Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6')).map((header, index) => {
        const id = `toc_${index}`;
        header.id = id;
        return { id, text: header.textContent };
      });
    });
  </script>
  
  <div>
    <h2>Table of Contents</h2>
    <ul>
      {#each headers as { id, text }}
        <li><a href="#{id}">{text}</a></li>
      {/each}
    </ul>
  </div>  
</script>

Okay, well it is has lost one feature and gain a new one.

  • New ‘Table of Contents’ banner. :joy:
  • It is now scanning the entire document for headers - should be limited to the post-body.

Testing Fail

In testing the ‘Table of Contents’ component, I have noticed that it is gaining new headers on navigation!

Time to tweak the prompt!


Version 2.0

Write a svelte component which reads the inner html content from a given element on the current page and creates a 'table of contents' from all of the headers it finds.
- The 'table of contents' should be rendered as a html bullet list.    
- It should create dynamic anchor links for each html header it finds.
- each entry in the 'table of contents' should be a link to the corresponding header on the page.
- use typescript and svelte to create the component.
- the component should accept input for which elementId to use for finding children headers.
- upon navigation, the component shouldn't be gaining new headers.  Does it need to be cleared on navigation?

This prompt is probably not great, but lets see what we get back!

<script lang="ts">
  import { onMount } from 'svelte';
  import { writable } from 'svelte/store';

  export let elementId: string;

  const headers = writable([]);

  onMount(() => {
    const element = document.getElementById(elementId);
    if (element) {
      const foundHeaders = element.querySelectorAll('h1, h2, h3, h4, h5, h6');
      headers.set(Array.from(foundHeaders).map((header, index) => {
        const id = `toc_${index}`;
        header.id = id;
        return { id, text: header.innerText };
      }));
    }
  });
</script>

<style>
  .toc {
    list-style-type: disc;
    margin-left: 20px;
  }
  .toc-item {
    cursor: pointer;
  }
</style>

<ul class="toc">
  {#each $headers as header (header.id)}
    <li class="toc-item">
      <a href="#{header.id}">{header.text}</a>
    </li>
  {/each}
</ul>

How’d it go this time?

Below is a screen shot and it looks like it is working correctly. Navigation to other pages no longer causes a growing list of table of contents.

table of contents prompt 2.0


Version 3.0

We have achieved the basic requirements, let’s see if we can add some more advanced features and then we will look at styling and page position (sticky etc).

For this I am going to provide Copilot with both a prompt and the current script.

I have a svelte component below which reads the inner html content from a given element on the current page and creates a 'table of contents' from all of the headers it finds.  

Can you modify the script to do the following:
- Change the 'table of contents' to reflect the hierarchy with indention.
- As the user scrolls down the page highlight the relevant header in the 'table of contents'.

<script hidden for brevity>

This is starting to get more complicated… let’s see how Microsoft Copilot goes!

<script lang="ts">
  import { onMount, afterUpdate } from 'svelte';
  import { writable } from 'svelte/store';

  export let elementId: string;

  const headers = writable([]);

  onMount(() => {
    const element = document.getElementById(elementId);
    if (element) {
      const foundHeaders = element.querySelectorAll('h1, h2, h3, h4, h5, h6');
      headers.set(Array.from(foundHeaders).map((header, index) => {
        const id = `toc_${index}`;
        header.id = id;
        return { id, text: header.innerText, tagName: header.tagName };
      }));
    }

    window.addEventListener('scroll', highlightOnScroll);
  });

  afterUpdate(() => {
    document.querySelector(`.toc-item`).classList.remove('highlight');
    window.removeEventListener('scroll', highlightOnScroll);
    window.addEventListener('scroll', highlightOnScroll);
  });

  function highlightOnScroll() {
    $headers.forEach(header => {
      const element = document.getElementById(header.id);
      const rect = element.getBoundingClientRect();
      const isInViewport = rect.top >= 0 && rect.bottom <= window.innerHeight;
      if (isInViewport) {
        document.querySelector(`.toc-item[data-id="${header.id}"]`).classList.add('highlight');
      } else {
        document.querySelector(`.toc-item[data-id="${header.id}"]`).classList.remove('highlight');
      }
    });
  }
</script>

<style>
  .toc {
    list-style-type: none;
    padding-left: 0;
  }
  .toc-item {
    cursor: pointer;
  }
  .toc-item[data-tag="H1"] {
    padding-left: 0;
  }
  .toc-item[data-tag="H2"] {
    padding-left: 10px;
  }
  .toc-item[data-tag="H3"] {
    padding-left: 20px;
  }
  .toc-item[data-tag="H4"] {
    padding-left: 30px;
  }
  .toc-item[data-tag="H5"] {
    padding-left: 40px;
  }
  .toc-item[data-tag="H6"] {
    padding-left: 50px;
  }
  .highlight {
    background-color: yellow;
  }
</style>

<ul class="toc">
  {#each $headers as header (header.id)}
    <li class="toc-item" data-id={header.id} data-tag={header.tagName}>
      <a href="#{header.id}">{header.text}</a>
    </li>
  {/each}
</ul>

Couple of minor tweaks

+ return { id, text: header.textContent, tagName: header.tagName };
- return { id, text: header.innerText, tagName: header.tagName }; 
  .toc {
    list-style-type: none;
    padding-left: 0;
+   position: fixed;
+   top: 200px;   
  } 

Conclusion

I’m not particularly proficient at Svelte and although I’d love to learn it more deeply, maybe for the sake of this blog I should leverage more of my time on actually writing posts.

I think this was a great exercise for using AI and I am very happy with the result. Did it actually save me time? I’m not sure, did I learn how to do some new things in Svelte? - Yes.

It’s still a bit hit or miss with AI - But productivity gains are still significant.

Contents