Skip to content

Commit de5d8cb

Browse files
committed
Add top-level domain as default for all links
- Add optional top-level 'domain' field in YAML config - Links inherit from top-level domain unless they specify their own - Update validation to require domain either at top-level or per-link - Add tests for new domain inheritance behavior - Update README with new schema documentation
1 parent b58db01 commit de5d8cb

File tree

5 files changed

+158
-16
lines changed

5 files changed

+158
-16
lines changed

README.md

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,23 @@ A GitHub Action that syncs short links from a `shortio.yaml` file in your reposi
2121

2222
```yaml
2323
# shortio.yaml
24+
domain: "short.example.com" # Default domain for all links
25+
2426
links:
2527
docs:
2628
url: "https://documentation.example.com/v2"
27-
domain: "short.example.com"
2829
title: "Documentation"
2930
tags:
3031
- docs
3132
- public
3233

3334
api:
3435
url: "https://api.example.com"
35-
domain: "short.example.com"
3636
title: "API Reference"
3737

3838
blog:
3939
url: "https://blog.example.com"
40-
domain: "links.company.io"
40+
domain: "links.company.io" # Override default domain
4141
title: "Company Blog"
4242
```
4343
@@ -84,16 +84,25 @@ jobs:
8484

8585
### YAML Schema
8686

87-
The `links` field is a map where each key is the slug (short path) and the value contains:
87+
**Top-level fields:**
88+
89+
| Field | Type | Required | Description |
90+
|-------|------|----------|-------------|
91+
| `domain` | string | No | Default domain for all links |
92+
| `links` | map | Yes | Map of slug → link configuration |
93+
94+
**Link fields** (within `links` map):
8895

8996
| Field | Type | Required | Description |
9097
|-------|------|----------|-------------|
9198
| *(key)* | string | Yes | The slug/short path (e.g., `docs` → short.example.com/docs) |
9299
| `url` | string | Yes | Destination URL |
93-
| `domain` | string | Yes | Short.io domain to use |
100+
| `domain` | string | No* | Short.io domain (required if no top-level `domain`) |
94101
| `title` | string | No | Link title for organization |
95102
| `tags` | string[] | No | Tags for categorization |
96103

104+
*Each link must have a domain, either from the top-level `domain` or its own `domain` field.
105+
97106
## Examples
98107

99108
### Dry-run mode

src/config.test.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,69 @@ links:
8686
expect(() => parseConfig(TEST_FILE)).toThrow('Link "my-link" must have a non-empty "url" string');
8787
});
8888

89-
it('throws ConfigError for missing domain', () => {
89+
it('throws ConfigError for missing domain when no top-level domain', () => {
9090
writeTestConfig(`
9191
links:
9292
my-link:
9393
url: https://example.com
9494
`);
9595
expect(() => parseConfig(TEST_FILE)).toThrow(ConfigError);
96-
expect(() => parseConfig(TEST_FILE)).toThrow('Link "my-link" must have a non-empty "domain" string');
96+
expect(() => parseConfig(TEST_FILE)).toThrow('Link "my-link" must have a "domain" (or set top-level "domain")');
97+
});
98+
99+
it('parses config with top-level domain', () => {
100+
writeTestConfig(`
101+
domain: short.io
102+
links:
103+
my-link:
104+
url: https://example.com
105+
another-link:
106+
url: https://test.com
107+
title: Test Link
108+
`);
109+
const config = parseConfig(TEST_FILE);
110+
expect(config.domain).toBe('short.io');
111+
expect(Object.keys(config.links)).toHaveLength(2);
112+
expect(config.links['my-link'].domain).toBeUndefined();
113+
expect(config.links['another-link'].domain).toBeUndefined();
114+
});
115+
116+
it('allows per-link domain to override top-level domain', () => {
117+
writeTestConfig(`
118+
domain: default.io
119+
links:
120+
my-link:
121+
url: https://example.com
122+
override-link:
123+
url: https://test.com
124+
domain: custom.io
125+
`);
126+
const config = parseConfig(TEST_FILE);
127+
expect(config.domain).toBe('default.io');
128+
expect(config.links['my-link'].domain).toBeUndefined();
129+
expect(config.links['override-link'].domain).toBe('custom.io');
130+
});
131+
132+
it('throws ConfigError for empty top-level domain', () => {
133+
writeTestConfig(`
134+
domain: ""
135+
links:
136+
my-link:
137+
url: https://example.com
138+
`);
139+
expect(() => parseConfig(TEST_FILE)).toThrow(ConfigError);
140+
expect(() => parseConfig(TEST_FILE)).toThrow('Top-level "domain" must be a non-empty string');
141+
});
142+
143+
it('throws ConfigError for non-string top-level domain', () => {
144+
writeTestConfig(`
145+
domain: 123
146+
links:
147+
my-link:
148+
url: https://example.com
149+
`);
150+
expect(() => parseConfig(TEST_FILE)).toThrow(ConfigError);
151+
expect(() => parseConfig(TEST_FILE)).toThrow('Top-level "domain" must be a non-empty string');
97152
});
98153

99154
it('throws ConfigError for invalid URL', () => {
@@ -180,4 +235,31 @@ describe('getUniqueDomains', () => {
180235
const domains = getUniqueDomains(config);
181236
expect(domains).toEqual([]);
182237
});
238+
239+
it('uses top-level domain when link domain is not specified', () => {
240+
const config = {
241+
domain: 'default.io',
242+
links: {
243+
'link1': { url: 'https://example.com' },
244+
'link2': { url: 'https://test.com', domain: 'custom.io' },
245+
},
246+
};
247+
const domains = getUniqueDomains(config);
248+
expect(domains).toHaveLength(2);
249+
expect(domains).toContain('default.io');
250+
expect(domains).toContain('custom.io');
251+
});
252+
253+
it('returns only top-level domain when all links use it', () => {
254+
const config = {
255+
domain: 'default.io',
256+
links: {
257+
'link1': { url: 'https://example.com' },
258+
'link2': { url: 'https://test.com' },
259+
},
260+
};
261+
const domains = getUniqueDomains(config);
262+
expect(domains).toHaveLength(1);
263+
expect(domains).toContain('default.io');
264+
});
183265
});

src/config.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ function validateConfig(config: unknown): asserts config is YamlConfig {
3030

3131
const obj = config as Record<string, unknown>;
3232

33+
const defaultDomain = obj.domain;
34+
if (defaultDomain !== undefined && (typeof defaultDomain !== 'string' || defaultDomain.trim() === '')) {
35+
throw new ConfigError('Top-level "domain" must be a non-empty string');
36+
}
37+
3338
if (!obj.links || typeof obj.links !== 'object' || Array.isArray(obj.links)) {
3439
throw new ConfigError('Config must have a "links" map (use slug as key)');
3540
}
@@ -41,18 +46,19 @@ function validateConfig(config: unknown): asserts config is YamlConfig {
4146
if (!slug || slug.trim() === '') {
4247
throw new ConfigError('Link slug (key) must be a non-empty string');
4348
}
44-
validateLink(link, slug);
49+
validateLink(link, slug, defaultDomain as string | undefined);
4550

4651
const validatedLink = link as YamlLinkValue;
47-
const key = `${validatedLink.domain}/${slug}`;
52+
const domain = validatedLink.domain ?? defaultDomain;
53+
const key = `${domain}/${slug}`;
4854
if (seen.has(key)) {
4955
throw new ConfigError(`Duplicate link: ${key}`);
5056
}
5157
seen.add(key);
5258
}
5359
}
5460

55-
function validateLink(link: unknown, slug: string): asserts link is YamlLinkValue {
61+
function validateLink(link: unknown, slug: string, defaultDomain?: string): asserts link is YamlLinkValue {
5662
if (!link || typeof link !== 'object') {
5763
throw new ConfigError(`Link "${slug}" must be an object`);
5864
}
@@ -63,8 +69,12 @@ function validateLink(link: unknown, slug: string): asserts link is YamlLinkValu
6369
throw new ConfigError(`Link "${slug}" must have a non-empty "url" string`);
6470
}
6571

66-
if (typeof obj.domain !== 'string' || obj.domain.trim() === '') {
67-
throw new ConfigError(`Link "${slug}" must have a non-empty "domain" string`);
72+
if (obj.domain !== undefined) {
73+
if (typeof obj.domain !== 'string' || obj.domain.trim() === '') {
74+
throw new ConfigError(`Link "${slug}" "domain" must be a non-empty string`);
75+
}
76+
} else if (!defaultDomain) {
77+
throw new ConfigError(`Link "${slug}" must have a "domain" (or set top-level "domain")`);
6878
}
6979

7080
try {
@@ -91,7 +101,7 @@ function validateLink(link: unknown, slug: string): asserts link is YamlLinkValu
91101

92102
export function getUniqueDomains(config: YamlConfig): string[] {
93103
const domains = new Set<string>();
94-
for (const link of Object.values(config.links)) {
104+
for (const link of getLinksArray(config)) {
95105
domains.add(link.domain);
96106
}
97107
return Array.from(domains);

src/types.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,15 @@ describe('getLinksArray', () => {
3030
slug: 'link-a',
3131
url: 'https://a.com',
3232
domain: 'short.io',
33+
title: undefined,
34+
tags: undefined,
3335
});
3436
expect(links).toContainEqual({
3537
slug: 'link-b',
3638
url: 'https://b.com',
3739
domain: 'short.io',
3840
title: 'Link B',
41+
tags: undefined,
3942
});
4043
});
4144

@@ -58,4 +61,34 @@ describe('getLinksArray', () => {
5861
const links = getLinksArray(config);
5962
expect(links[0].tags).toEqual(['tag1', 'tag2']);
6063
});
64+
65+
it('applies top-level domain when link domain is not specified', () => {
66+
const config: YamlConfig = {
67+
domain: 'default.io',
68+
links: {
69+
'link-a': { url: 'https://a.com' },
70+
'link-b': { url: 'https://b.com' },
71+
},
72+
};
73+
const links = getLinksArray(config);
74+
expect(links).toHaveLength(2);
75+
expect(links[0].domain).toBe('default.io');
76+
expect(links[1].domain).toBe('default.io');
77+
});
78+
79+
it('allows per-link domain to override top-level domain', () => {
80+
const config: YamlConfig = {
81+
domain: 'default.io',
82+
links: {
83+
'link-a': { url: 'https://a.com' },
84+
'link-b': { url: 'https://b.com', domain: 'custom.io' },
85+
},
86+
};
87+
const links = getLinksArray(config);
88+
expect(links).toHaveLength(2);
89+
const linkA = links.find(l => l.slug === 'link-a');
90+
const linkB = links.find(l => l.slug === 'link-b');
91+
expect(linkA?.domain).toBe('default.io');
92+
expect(linkB?.domain).toBe('custom.io');
93+
});
6194
});

src/types.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
export interface YamlLinkValue {
22
url: string;
3-
domain: string;
3+
domain?: string;
44
title?: string;
55
tags?: string[];
66
}
77

8-
export interface YamlLink extends YamlLinkValue {
8+
export interface YamlLink {
99
slug: string;
10+
url: string;
11+
domain: string;
12+
title?: string;
13+
tags?: string[];
1014
}
1115

1216
export interface YamlConfig {
17+
domain?: string;
1318
links: Record<string, YamlLinkValue>;
1419
}
1520

@@ -65,6 +70,9 @@ export function getLinkKey(domain: string, slug: string): LinkKey {
6570
export function getLinksArray(config: YamlConfig): YamlLink[] {
6671
return Object.entries(config.links).map(([slug, value]) => ({
6772
slug,
68-
...value,
73+
url: value.url,
74+
domain: value.domain ?? config.domain!,
75+
title: value.title,
76+
tags: value.tags,
6977
}));
7078
}

0 commit comments

Comments
 (0)