Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Lightweight, composable utilities for documentation sites.
- **compass** — Headless tree navigation state machine (pure, portable to any runtime)
- **teleport** — Keyboard bindings + DOM integration for compass
- **lantern** — Theme toggle with flash-free hydration
- **atlas** — Magic links with build-time resolution and broken link detection
- **atlas** — Remark plugin for Wikipedia-style `[[magic links]]`
- **lighthouse** — 404 recovery with fuzzy matching

Core packages are framework-agnostic. Astro wrappers available for teleport, lantern, and lighthouse.
Expand Down
103 changes: 31 additions & 72 deletions packages/atlas/README.md
Original file line number Diff line number Diff line change
@@ -1,90 +1,49 @@
# atlas

Wikipedia-style magic links with build-time resolution and broken link detection.
Remark plugin for Wikipedia-style magic links.

## What Ships

```
atlas/
├── remark-magic-links.mjs # Transforms :id and [[id]] syntax to URLs
├── link-resolver.ts # Core resolution logic
├── link-checker.ts # Broken link detection + reporting
└── index.ts # Unified API
```

## Link Syntax
## Syntax

```markdown
<!-- Colon syntax -->
Check out [:context] for more info.
See [:context|:ctx|:context-management] for fallback resolution.

<!-- Wiki bracket syntax -->
Check out [[context]] for more info.
[[context|Learn about context]] with custom display text.
```
<!-- Wiki syntax (default) -->
Check out [[context-collapse]] for more.
[[context-collapse|Learn about context]] with custom display text.

## API

```typescript
interface LinkTarget {
id: string;
slug: string;
url: string;
aliases?: string[];
placeholder?: boolean;
}

type ResolveResult =
| { status: 'resolved'; target: LinkTarget }
| { status: 'placeholder'; target: LinkTarget }
| { status: 'unresolved'; id: string };

function createLinkResolver(targets: LinkTarget[]): {
resolve(id: string): ResolveResult;
resolveFirst(ids: string[]): ResolveResult;
};

function remarkMagicLinks(config: {
targets: LinkTarget[];
syntax?: 'colon' | 'wiki' | 'both';
unresolvedBehavior?: 'text' | 'warn' | 'error';
placeholderClass?: string;
}): RemarkPlugin;

function checkLinks(config: {
contentDir: string;
targets: LinkTarget[];
}): {
broken: { file: string; line: number; id: string }[];
placeholders: { file: string; line: number; id: string }[];
};
<!-- Colon syntax -->
Check out [:context-collapse] for more.
[:context-collapse|Learn about context] with custom display text.
```

## Resolution Priority

1. Exact `id` match
2. Match in `aliases` array
3. Slug fallback
4. Unresolved → plain text (graceful degradation)

## Usage

```javascript
// astro.config.mjs
import { remarkMagicLinks } from 'atlas';

const targets = concepts.map(c => ({
id: c.data.id || c.slug,
slug: c.slug,
url: `/concepts/${c.slug}/`,
aliases: c.data.aliases,
placeholder: c.data.placeholder,
}));
import { remarkMagicLinks } from '@sailkit/atlas';

export default {
markdown: {
remarkPlugins: [[remarkMagicLinks, { targets }]],
remarkPlugins: [
[remarkMagicLinks, {
urlBuilder: (id) => `/concepts/${id}/`,
}],
],
},
};
```

## API

```typescript
remarkMagicLinks(config: {
/** Build URL from link ID */
urlBuilder: (id: string) => string;
/** Syntax style: 'wiki' (default), 'colon', or 'both' */
syntax?: 'wiki' | 'colon' | 'both';
}): RemarkPlugin;
```

## How It Works

1. You write `[[some-page]]` in markdown
2. Plugin finds the syntax in text nodes (not code blocks)
3. Transforms to `[some-page](/concepts/some-page/)` using your `urlBuilder`
56 changes: 56 additions & 0 deletions packages/atlas/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "@sailkit/atlas",
"version": "0.1.0",
"description": "Wikipedia-style magic links with build-time resolution",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./remark": {
"import": "./dist/remark-magic-links.js",
"types": "./dist/remark-magic-links.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@types/mdast": "^4.0.0",
"@types/node": "^20.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"typescript": "^5.3.0",
"unified": "^11.0.0",
"vitest": "^2.0.0"
},
"peerDependencies": {
"unified": ">=10.0.0"
},
"peerDependenciesMeta": {
"unified": {
"optional": true
}
},
"keywords": [
"remark",
"mdast",
"magic-links",
"wiki-links",
"markdown",
"astro"
],
"license": "MIT"
}
2 changes: 2 additions & 0 deletions packages/atlas/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { LinkSyntax, RemarkMagicLinksConfig } from './remark-magic-links.js';
export { remarkMagicLinks, default as remarkMagicLinksDefault } from './remark-magic-links.js';
105 changes: 105 additions & 0 deletions packages/atlas/src/remark-magic-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { remarkMagicLinks } from './remark-magic-links.js';

const urlBuilder = (id: string) => `/concepts/${id}/`;

async function process(markdown: string, config: Partial<Parameters<typeof remarkMagicLinks>[0]> = {}) {
const result = await unified()
.use(remarkParse)
.use(remarkMagicLinks, { urlBuilder, ...config })
.use(remarkStringify)
.process(markdown);
return String(result);
}

describe('remarkMagicLinks', () => {
describe('wiki syntax (default)', () => {
it('transforms [[id]] to links', async () => {
const result = await process('Check out [[context-collapse]] for more info.');
expect(result).toContain('[context-collapse](/concepts/context-collapse/)');
});

it('supports custom display text [[id|text]]', async () => {
const result = await process('Learn about [[hallucination|AI hallucinations]].');
expect(result).toContain('[AI hallucinations](/concepts/hallucination/)');
});

it('handles multiple links in same paragraph', async () => {
const result = await process('See [[foo]] and [[bar]] for details.');
expect(result).toContain('[foo](/concepts/foo/)');
expect(result).toContain('[bar](/concepts/bar/)');
});
});

describe('colon syntax', () => {
it('transforms [:id] to links', async () => {
const result = await process('Check out [:context-collapse] for more info.', { syntax: 'colon' });
expect(result).toContain('[context-collapse](/concepts/context-collapse/)');
});

it('supports custom display text [:id|text]', async () => {
const result = await process('Learn about [:hallucination|AI hallucinations].', { syntax: 'colon' });
expect(result).toContain('[AI hallucinations](/concepts/hallucination/)');
});
});

describe('both syntax', () => {
it('handles mixed syntax in same document', async () => {
const result = await process(
'First [:foo] and then [[bar]].',
{ syntax: 'both' }
);
expect(result).toContain('[foo](/concepts/foo/)');
expect(result).toContain('[bar](/concepts/bar/)');
});
});

describe('edge cases', () => {
it('handles link at start of text', async () => {
const result = await process('[[foo]] is important.');
expect(result).toContain('[foo](/concepts/foo/)');
});

it('handles link at end of text', async () => {
const result = await process('Learn about [[foo]]');
expect(result).toContain('[foo](/concepts/foo/)');
});

it('preserves surrounding text', async () => {
const result = await process('Before [[foo]] after.');
expect(result).toContain('Before');
expect(result).toContain('after');
});

it('handles text with no magic links', async () => {
const result = await process('Just regular text here.');
expect(result).toContain('Just regular text here.');
});

it('does not match inside code blocks', async () => {
const result = await process('```\n[[not-a-link]]\n```');
expect(result).toContain('[[not-a-link]]');
expect(result).not.toContain('/concepts/not-a-link/');
});

it('does not match inside inline code', async () => {
const result = await process('Use `[[syntax]]` for links.');
expect(result).toContain('`[[syntax]]`');
});
});

describe('urlBuilder callback', () => {
it('uses custom urlBuilder', async () => {
const customBuilder = (id: string) => `/custom/${id}.html`;
const result = await unified()
.use(remarkParse)
.use(remarkMagicLinks, { urlBuilder: customBuilder })
.use(remarkStringify)
.process('See [[my-page]] here.');
expect(String(result)).toContain('[my-page](/custom/my-page.html)');
});
});
});
Loading