# jg-site

> Structured data, llms.txt files, and theme preferences for websites, as small functions that run in browsers and Node.js.

- **Version:** `0.1.0`
- **Runtimes:** Universal
- **Install:** `npm install jg-site`
- **On npm:** [jg-site](https://www.npmjs.com/package/jg-site)
- **Source:** [packages/jg-site](https://github.com/jesusgraterol/jg-packages/tree/main/packages/jg-site)
- **Recipes:** [jg-site recipes](https://jg-packages.jesusgraterol.dev/packages/jg-site/recipes/index.md)

## Usage

Describe a page to search engines with JSON-LD, and serialize it for a `<script type="application/ld+json">`. Every `<` is escaped, so text in the data can't close the script element:

```ts
import { buildStructuredData, serializeStructuredData } from 'jg-site';

const json = serializeStructuredData(
  buildStructuredData({
    breadcrumbs: [
      { name: 'Docs', path: '/' },
      { name: 'Install', path: '/install/' },
    ],
    canonicalUrl: 'https://docs.example.com/install/',
    description: 'Install the CLI with npm, pnpm, or Yarn, and check that it runs.',
    site: {
      author: { name: 'Ada Lovelace', url: 'https://ada.example.com/' },
      language: 'en',
      name: 'Example Docs',
      url: 'https://docs.example.com',
    },
    title: 'Install | Example Docs',
    type: 'webpage',
  }),
);
```

A page that builds the rest of its structured data itself can add just the breadcrumb. A step without a URL, such as the current page, has no `item`:

```ts
import { buildBreadcrumbStructuredData } from 'jg-site';

buildBreadcrumbStructuredData([
  { name: 'Home', url: 'https://example.com/' },
  { name: 'Blog', url: 'https://example.com/blog' },
  { name: 'Release notes' },
]);
```

Write an llms.txt in the [llmstxt.org](https://llmstxt.org/) format, and an llms-full.txt from the site's Markdown documents:

```ts
import { buildLlmsFullText, buildLlmsText } from 'jg-site';

const llmsText = buildLlmsText({
  name: 'Example Docs',
  summary: 'Guides and the API reference for the Example CLI.',
  sections: [
    {
      heading: 'Guides',
      links: [
        { title: 'Install', url: 'https://docs.example.com/install.md', description: 'Set it up.' },
        { title: 'Changelog', url: 'https://docs.example.com/changelog.md' },
      ],
    },
  ],
});
const llmsFullText = buildLlmsFullText({
  name: 'Example Docs',
  summary: 'Guides and the API reference for the Example CLI.',
  documents: ['# Install\n\nRun `npm install example-cli`.\n'],
});
```

Read and store a visitor's theme preference. Stored values are untrusted, so anything unknown reads as `'system'`, and a browser that blocks storage never throws:

```ts
import { readThemePreference, resolveThemePreference, writeThemePreference } from 'jg-site';

const preference = readThemePreference('example-theme'); // 'system', 'light', or 'dark'
const theme = resolveThemePreference(
  preference,
  matchMedia('(prefers-color-scheme: dark)').matches,
); // 'light' or 'dark'
writeThemePreference('example-theme', 'dark'); // false when the browser blocks storage
```

`SEARCH_TEXT_LIMITS` holds the lengths that keep titles and descriptions whole in search results, for build checks.

## <a id="api-reference"></a>jg-site API reference

Every export of `jg-site@0.1.0`, 24 in all, with its signature, its errors, and an example. It's generated from the package's TSDoc.

- Functions: [`buildBreadcrumbStructuredData`](#api-buildBreadcrumbStructuredData), [`buildLlmsFullText`](#api-buildLlmsFullText), [`buildLlmsText`](#api-buildLlmsText), [`buildStructuredData`](#api-buildStructuredData), [`escapeMarkdownLabel`](#api-escapeMarkdownLabel), [`normalizeDescription`](#api-normalizeDescription), [`parseThemePreference`](#api-parseThemePreference), [`readThemePreference`](#api-readThemePreference), [`resolveThemePreference`](#api-resolveThemePreference), [`serializeStructuredData`](#api-serializeStructuredData), [`writeThemePreference`](#api-writeThemePreference)
- Constants: [`SEARCH_TEXT_LIMITS`](#api-SEARCH_TEXT_LIMITS), [`THEME_PREFERENCES`](#api-THEME_PREFERENCES)
- Types: [`IListedPage`](#api-IListedPage), [`ILlmsFullTextOptions`](#api-ILlmsFullTextOptions), [`ILlmsTextLink`](#api-ILlmsTextLink), [`ILlmsTextOptions`](#api-ILlmsTextOptions), [`ILlmsTextSection`](#api-ILlmsTextSection), [`IResolvedTheme`](#api-IResolvedTheme), [`IStructuredDataOptions`](#api-IStructuredDataOptions), [`IStructuredDataSite`](#api-IStructuredDataSite), [`IStructuredDataSourceCode`](#api-IStructuredDataSourceCode), [`IThemePreference`](#api-IThemePreference), [`IThemeStorage`](#api-IThemeStorage)

### <a id="api-functions"></a>Functions

#### <a id="api-buildBreadcrumbStructuredData"></a>`buildBreadcrumbStructuredData`

*Function*

Builds a standalone BreadcrumbList for a page whose structured data is assembled elsewhere, from the same steps its visible breadcrumb shows. A step without a URL, such as the current page, gets no `item`, as schema.org allows for the last step.

```ts
buildBreadcrumbStructuredData(
  items: readonly { name: string; url?: string }[],
): Record<string, unknown>
```

**Parameters**

- `items` `readonly { name: string; url?: string }[]`: The steps, from the home page to this page, each with its absolute URL.

**Returns** `Record<string, unknown>`: The BreadcrumbList's JSON-LD.

**Example**

```ts
import { buildBreadcrumbStructuredData } from 'jg-site';

buildBreadcrumbStructuredData([
  { name: 'Home', url: 'https://example.com/' },
  { name: 'Blog', url: 'https://example.com/blog' },
  { name: 'Release notes' },
]);
// a BreadcrumbList whose last ListItem has a name and a position, and no item
```

#### <a id="api-buildLlmsFullText"></a>`buildLlmsFullText`

*Function*

Gathers a site's documents into one file, as llms-full.txt: the site's name and summary, then each document, separated by a rule.

```ts
buildLlmsFullText(options: ILlmsFullTextOptions): string
```

**Parameters**

- `options` `ILlmsFullTextOptions`: The name, the summary, and the documents, each a Markdown file.

**Returns** `string`: The file's text.

**Example**

```ts
import { buildLlmsFullText } from 'jg-site';

buildLlmsFullText({ name: 'jg-packages', summary: 'Packages for agents.', documents: ['# jg-fetch\n'] });
// the title and the summary, a rule, then the jg-fetch document
```

#### <a id="api-buildLlmsText"></a>`buildLlmsText`

*Function*

Writes an llms.txt in the llmstxt.org format: the site's name as the title, its summary as a blockquote, optional details, then one `##` section per group of links. Each link's label is escaped, and its description, when it has one, kept to one line. A section can carry a Markdown body instead of links, such as an install command in a code block, written as it is.

```ts
buildLlmsText(options: ILlmsTextOptions): string
```

**Parameters**

- `options` `ILlmsTextOptions`: The name, the summary, the details, and the sections.

**Returns** `string`: The file's text.

**Example**

```ts
import { buildLlmsText } from 'jg-site';

buildLlmsText({
  name: 'jg-packages',
  summary: 'Small, tested TypeScript packages for coding agents.',
  sections: [
    {
      heading: 'Packages',
      links: [
        {
          title: 'jg-fetch',
          url: 'https://jg-packages.jesusgraterol.dev/packages/jg-fetch/index.md',
          description: 'A fetch wrapper that never throws.',
        },
      ],
    },
  ],
});
// a title, a blockquote, and a `## Packages` section with one link
```

#### <a id="api-buildStructuredData"></a>`buildStructuredData`

*Function*

Builds the JSON-LD for one page: the WebSite, with the pages it lists, for the home page, and a WebPage within it, with its breadcrumb and the source code it documents, for every other page.

```ts
buildStructuredData(options: IStructuredDataOptions): Record<string, unknown>
```

**Parameters**

- `options` `IStructuredDataOptions`: What the page describes.

**Returns** `Record<string, unknown>`: The page's JSON-LD.

**Example**

```ts
import { buildStructuredData } from 'jg-site';

const structuredData = buildStructuredData({
  breadcrumbs: [
    { name: 'jg-packages', path: '/' },
    { name: 'jg-fetch', path: '/packages/jg-fetch/' },
  ],
  canonicalUrl: 'https://jg-packages.jesusgraterol.dev/packages/jg-fetch/',
  description: 'A fetch wrapper that never throws.',
  site: {
    author: { name: 'Jesus Graterol', url: 'https://jesusgraterol.dev/' },
    language: 'en',
    name: 'jg-packages',
    url: 'https://jg-packages.jesusgraterol.dev',
  },
  title: 'jg-fetch: HTTP requests | jg-packages',
  type: 'webpage',
});
// a WebPage with its BreadcrumbList, for a <script type="application/ld+json">
```

#### <a id="api-escapeMarkdownLabel"></a>`escapeMarkdownLabel`

*Function*

Escapes text for use as a Markdown link's label, so a bracket in it can't end the label.

```ts
escapeMarkdownLabel(label: string): string
```

**Parameters**

- `label` `string`: The label, such as a page's name.

**Returns** `string`: The escaped label.

**Example**

```ts
import { escapeMarkdownLabel } from 'jg-site';

escapeMarkdownLabel('[draft] notes'); // '\[draft\] notes'
```

#### <a id="api-normalizeDescription"></a>`normalizeDescription`

*Function*

Collapses prose into one line for a Markdown list item, so a description written across lines stays one item.

```ts
normalizeDescription(description: string): string
```

**Parameters**

- `description` `string`: The description.

**Returns** `string`: The description on one line, without surrounding whitespace.

**Example**

```ts
import { normalizeDescription } from 'jg-site';

normalizeDescription('Typed stores,\n  for pages and workers. '); // 'Typed stores, for pages and workers.'
```

#### <a id="api-parseThemePreference"></a>`parseThemePreference`

*Function*

Reads a stored value as a theme preference, falling back to the system's theme for anything else, because storage holds untrusted text.

```ts
parseThemePreference(value: unknown): IThemePreference
```

**Parameters**

- `value` `unknown`: The stored value.

**Returns** `IThemePreference`: The preference.

**Example**

```ts
import { parseThemePreference } from 'jg-site';

parseThemePreference('dark'); // 'dark'
parseThemePreference('purple'); // 'system'
```

#### <a id="api-readThemePreference"></a>`readThemePreference`

*Function*

Reads the preference stored under a key, or the system's theme when there is none or storage is unavailable, such as when the browser blocks it.

```ts
readThemePreference(
  storageKey: string,
  storage?: IThemeStorage,
): IThemePreference
```

**Parameters**

- `storageKey` `string`: The key the site stores its preference under, such as 'jg-packages-theme'.
- `storage?` `IThemeStorage`: The storage. Defaults to localStorage.

**Returns** `IThemePreference`: The preference.

**Example**

```ts
import { readThemePreference } from 'jg-site';

readThemePreference('jg-packages-theme'); // 'dark' after a visitor chose the dark theme
```

#### <a id="api-resolveThemePreference"></a>`resolveThemePreference`

*Function*

Resolves a preference into the theme the page applies.

```ts
resolveThemePreference(
  preference: IThemePreference,
  systemPrefersDark: boolean,
): IResolvedTheme
```

**Parameters**

- `preference` `IThemePreference`: The preference.
- `systemPrefersDark` `boolean`: Whether the operating system prefers a dark theme.

**Returns** `IResolvedTheme`: 'light' or 'dark'.

**Example**

```ts
import { resolveThemePreference } from 'jg-site';

resolveThemePreference('system', true); // 'dark'
resolveThemePreference('light', true); // 'light'
```

#### <a id="api-serializeStructuredData"></a>`serializeStructuredData`

*Function*

Serializes JSON-LD for a `<script type="application/ld+json">`, escaping every `<`, so text in the data, such as a description that mentions `</script>`, can't end the script element.

```ts
serializeStructuredData(structuredData: unknown): string
```

**Parameters**

- `structuredData` `unknown`: The JSON-LD.

**Returns** `string`: The serialized JSON.

**Example**

```ts
import { serializeStructuredData } from 'jg-site';

serializeStructuredData({ name: '</script>' }); // '{"name":"\\u003c/script>"}'
```

#### <a id="api-writeThemePreference"></a>`writeThemePreference`

*Function*

Stores a preference under a key, and reports whether it could, because the browser can block storage. The page still applies the preference when it can't.

```ts
writeThemePreference(
  storageKey: string,
  preference: IThemePreference,
  storage?: IThemeStorage,
): boolean
```

**Parameters**

- `storageKey` `string`: The key the site stores its preference under.
- `preference` `IThemePreference`: The preference.
- `storage?` `IThemeStorage`: The storage. Defaults to localStorage.

**Returns** `boolean`: Whether the preference was stored.

**Example**

```ts
import { writeThemePreference } from 'jg-site';

writeThemePreference('jg-packages-theme', 'light'); // true
```

### <a id="api-constants"></a>Constants

#### <a id="api-SEARCH_TEXT_LIMITS"></a>`SEARCH_TEXT_LIMITS`

*Constant*

length limits that keep titles and descriptions whole in search results

```ts
const SEARCH_TEXT_LIMITS: { descriptionMaxLength: 160; descriptionMinLength: 70; titleMaxLength: 60 }
```

#### <a id="api-THEME_PREFERENCES"></a>`THEME_PREFERENCES`

*Constant*

every preference a visitor can choose

```ts
const THEME_PREFERENCES: readonly IThemePreference[]
```

### <a id="api-types"></a>Types

#### <a id="api-IListedPage"></a>`IListedPage`

*Type*

one page the home page lists, in display order

```ts
type IListedPage = {
    name: string;
    path: string;
};
```

**Members**

- `name: string` (property)
- `path: string` (property)

#### <a id="api-ILlmsFullTextOptions"></a>`ILlmsFullTextOptions`

*Type*

a site's documents gathered into one file for a context window

```ts
type ILlmsFullTextOptions = {
    documents: readonly string[];
    name: string;
    summary: string;
};
```

**Members**

- `documents: readonly string[]` (property)
- `name: string` (property)
- `summary: string` (property)

#### <a id="api-ILlmsTextLink"></a>`ILlmsTextLink`

*Type*

one link of an llms.txt section, with what the reader finds there when the title alone doesn't say

```ts
type ILlmsTextLink = {
    description?: string | undefined;
    title: string;
    url: string;
};
```

**Members**

- `description: string` (property)
- `title: string` (property)
- `url: string` (property)

#### <a id="api-ILlmsTextOptions"></a>`ILlmsTextOptions`

*Type*

what an llms.txt says, in the llmstxt.org format

```ts
type ILlmsTextOptions = {
    details?: readonly string[] | undefined;
    name: string;
    sections: readonly ILlmsTextSection[];
    summary: string;
};
```

**Members**

- `details: readonly string[]` (property): paragraphs after the summary, before the sections
- `name: string` (property)
- `sections: readonly ILlmsTextSection[]` (property)
- `summary: string` (property): one sentence on what the site is, the blockquote under the title

#### <a id="api-ILlmsTextSection"></a>`ILlmsTextSection`

*Type*

one `##` section of an llms.txt: a list of links, or a Markdown body, such as an install command in a code block

```ts
type ILlmsTextSection = {
    heading: string;
    links: readonly ILlmsTextLink[];
} | {
    body: string;
    heading: string;
};
```

#### <a id="api-IResolvedTheme"></a>`IResolvedTheme`

*Type*

the theme the page applies: light or dark

```ts
type IResolvedTheme = Exclude<IThemePreference, 'system'>;
```

#### <a id="api-IStructuredDataOptions"></a>`IStructuredDataOptions`

*Type*

the values one page's JSON-LD describes

```ts
type IStructuredDataOptions = {
    breadcrumbs?: readonly {
        name: string;
        path: string;
    }[] | undefined;
    canonicalUrl: string;
    description: string;
    listedPages?: readonly IListedPage[] | undefined;
    site: IStructuredDataSite;
    sourceCode?: IStructuredDataSourceCode | undefined;
    title: string;
    type: 'website' | 'webpage';
};
```

**Members**

- `breadcrumbs: readonly { name: string; path: string }[]` (property): the hierarchy the page's breadcrumb shows, from the home page to this page, as site-relative paths
- `canonicalUrl: string` (property)
- `description: string` (property)
- `listedPages: readonly IListedPage[]` (property)
- `site: IStructuredDataSite` (property)
- `sourceCode: IStructuredDataSourceCode` (property)
- `title: string` (property)
- `type: "website" | "webpage"` (property): the home page is the website; every other page is a web page within it

#### <a id="api-IStructuredDataSite"></a>`IStructuredDataSite`

*Type*

the site every page's structured data belongs to

```ts
type IStructuredDataSite = {
    author: {
        name: string;
        url: string;
    };
    language: string;
    name: string;
    url: string;
};
```

**Members**

- `author: { name: string; url: string }` (property)
- `language: string` (property)
- `name: string` (property)
- `url: string` (property)

#### <a id="api-IStructuredDataSourceCode"></a>`IStructuredDataSourceCode`

*Type*

the source code a page documents, such as a package in the repository

```ts
type IStructuredDataSourceCode = {
    codeRepository: string;
    license: string;
    name: string;
    programmingLanguage: string;
    version: string;
};
```

**Members**

- `codeRepository: string` (property): where the code lives, such as the package's directory on GitHub
- `license: string` (property): the license's URL
- `name: string` (property)
- `programmingLanguage: string` (property)
- `version: string` (property)

#### <a id="api-IThemePreference"></a>`IThemePreference`

*Type*

a theme the visitor can choose: the system's, or a fixed light or dark one

```ts
type IThemePreference = 'system' | 'light' | 'dark';
```

#### <a id="api-IThemeStorage"></a>`IThemeStorage`

*Type*

the storage the preference is read from and written to, such as localStorage

```ts
type IThemeStorage = Pick<Storage, 'getItem' | 'setItem'>;
```
