appscript.dev
Automation Advanced Docs Sheets

Build a glossary that auto-links its terms

Hyperlink Northwind's defined terms throughout a Doc to their glossary anchors.

Published Nov 2, 2025

Northwind’s longer documents — onboarding packs, proposals, process handbooks — all carry their own jargon. The terms get defined once in a glossary, but nobody links every mention back to that definition by hand, so readers either already know the term or scroll hunting for it.

This script does the linking for you. It reads a glossary of terms from a Sheet, scans a Google Doc for every occurrence of each term, and turns each one into a hyperlink pointing at that term’s anchor in the glossary section. Update the glossary sheet, re-run it, and the whole document re-links itself.

What you’ll need

  • A Glossary sheet with two columns and a header row: term and definition.
  • The glossary sheet’s file ID, copied from its URL, set as GLOSSARY_ID in the config block.
  • A Google Doc whose glossary section uses bookmark-style anchors named glossary-<slug> — for example a term “Retainer” anchors at #glossary-retainer. The slug must match what slug() produces.
  • The file ID of the Doc you want to link, passed to autoLinkGlossary when you run it.

The script

// The Sheet holding the glossary terms and definitions.
const GLOSSARY_ID = '1abcGlossaryId';

/**
 * Scans a Doc and hyperlinks every occurrence of each glossary term
 * to its anchor in the glossary section.
 * @param {string} docId - The Google Doc to link.
 */
function autoLinkGlossary(docId) {
  // 1. Load the glossary terms from the Sheet.
  const terms = readSheet(GLOSSARY_ID);
  if (!terms.length) {
    Logger.log('Glossary is empty — nothing to link.');
    return;
  }

  // 2. Open the target document.
  const doc = DocumentApp.openById(docId);
  const body = doc.getBody();

  // 3. Walk each term and link every whole-word match in the body.
  for (const t of terms) {
    if (!t.term) continue;

    // \b…\b matches the term as a whole word, not inside another word.
    let found = body.findText('\\b' + t.term + '\\b');
    while (found) {
      const el = found.getElement();
      el.asText().setLinkUrl(
        found.getStartOffset(),
        found.getEndOffsetInclusive(),
        '#glossary-' + slug(t.term));

      // Resume the search from where this match ended.
      found = body.findText('\\b' + t.term + '\\b', found);
    }
  }

  doc.saveAndClose();
  Logger.log('Linked ' + terms.length + ' glossary terms.');
}

/**
 * Turns a term into a URL-safe slug for its anchor.
 * @param {string} s - The term.
 * @returns {string} A lower-case, hyphenated slug.
 */
function slug(s) {
  return s.toLowerCase().replace(/\W+/g, '-');
}

/**
 * Reads the first sheet of a spreadsheet into an array of row objects
 * keyed by the header row.
 * @param {string} id - The spreadsheet file ID.
 * @returns {Array<Object>} One object per data row.
 */
function readSheet(id) {
  const [header, ...rows] = SpreadsheetApp.openById(id)
    .getSheets()[0]
    .getDataRange()
    .getValues();
  return rows.map((r) =>
    Object.fromEntries(header.map((k, i) => [k, r[i]])));
}

How it works

  1. autoLinkGlossary calls readSheet to load the glossary, which returns one object per row keyed by the header — so each term is { term, definition }. If the glossary is empty it logs and stops.
  2. It opens the target Doc by ID and grabs the body element, the searchable container for all the document’s text.
  3. For each term it calls body.findText with a \b…\b regex. The word boundaries stop “art” from matching inside “start” — only whole-word hits are linked.
  4. The while loop walks every match of that term. For each one it reads the text element and offsets, then setLinkUrl turns just that span into a hyperlink pointing at #glossary-<slug>.
  5. Passing the previous found result back into findText resumes the search from where the last match ended, so it sweeps the whole document.
  6. slug lower-cases the term and replaces any run of non-word characters with a hyphen, producing the same anchor name the glossary section uses.
  7. saveAndClose writes the links back to the document.

Example run

Say the Glossary sheet holds:

termdefinition
RetainerAn ongoing monthly engagement.
Scope creepWork added beyond the agreed brief.

And the Doc contains the sentence:

Every Retainer protects against scope creep by fixing the brief up front.

After a run, “Retainer” becomes a link to #glossary-retainer and “scope creep” a link to #glossary-scope-creep. The plain text is unchanged — only the link styling and the click target are added.

Run it

This is an on-demand job — run it whenever a document is finished or the glossary changes:

  1. In the Apps Script editor, paste the target Doc’s ID into autoLinkGlossary — call it from a wrapper, or temporarily hard-code an ID to test.
  2. Select the function and click Run.
  3. Approve the authorisation prompt the first time.
  4. Open the Doc and check the linked terms.

Watch out for

  • Matching is case-sensitive. findText with \bRetainer\b will not catch “retainer” in lower case. List each casing you expect in the glossary, or normalise the term before searching.
  • Re-running re-links everything, including terms already linked. That is harmless — it just overwrites the same link — but it is not incremental.
  • Linking happens in glossary order. If one term is a substring of another (for example “Scope” and “Scope creep”), put the longer term first so the shorter one does not grab part of it.
  • The glossary anchors must already exist in the Doc. This script only creates the links; if #glossary-retainer is not a real anchor, the link will be dead. Build the glossary section with matching bookmark names first.
  • findText regex support is limited compared with JavaScript’s. Stick to simple patterns — \b and literal text are reliable, anything fancier may silently fail to match.

Related