@hajimism/dek 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,137 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative } from "node:path";
3
+ import { loadConfig } from "./config.ts";
4
+ import { DekError } from "./error.ts";
5
+ import { licenseText } from "./licenses.ts";
6
+ import { type DistOptions, distFile } from "./path.ts";
7
+ import { keepsInDeck, LICENSE_NAMES, MAX_SNAPSHOT_BYTES } from "./ref.ts";
8
+ import { isRefDeckName } from "./ref-name.ts";
9
+ import { followInside, requireInside, writeInside } from "./safe-fs.ts";
10
+
11
+ /**
12
+ * A deck handed over as a file, to be read as a ref by someone who cannot reach it on GitHub:
13
+ * a gzipped tar of a project that holds only that deck, under a folder named for it. The deck
14
+ * keeps what `dekc ref` keeps of a repository; the project's dek.toml goes empty, so the
15
+ * sender's refs and voice settings stay home. Its LICENSE is the one `packLicense` picks.
16
+ */
17
+ export async function packDeck(
18
+ project: { root: string },
19
+ deck: { name: string; dir: string },
20
+ options: { maxBytes?: number } = {},
21
+ ): Promise<Uint8Array> {
22
+ if (!isRefDeckName(deck.name)) {
23
+ throw new DekError(`deck "${deck.name}" cannot be read as a ref by that name`, {
24
+ path: deck.dir,
25
+ hint: "a ref names its deck with no space or @; rename the deck's folder, e.g. to 2026-10-talk, and pack it again",
26
+ });
27
+ }
28
+ // Each file by its path in the pack and where it really is, measured before any is read.
29
+ const sources: Array<readonly [string, string]> = deckFiles(deck.dir, project.root).map(
30
+ (path) =>
31
+ [`decks/${deck.name}/${path}`, requireInside(join(deck.dir, path), project.root)] as const,
32
+ );
33
+ const license = packLicense(project.root);
34
+ if (license?.path) {
35
+ sources.push([license.name, license.path]);
36
+ }
37
+ const maxBytes = options.maxBytes ?? MAX_SNAPSHOT_BYTES;
38
+ const size = sources.reduce((sum, [, real]) => sum + statSync(real).size, 0);
39
+ if (size > maxBytes) {
40
+ throw new DekError(
41
+ `deck "${deck.name}" is larger than a ref keeps, ${maxBytes / 1024 / 1024} MB`,
42
+ {
43
+ path: deck.dir,
44
+ hint: "a deck to read as a model should be far smaller; check its assets",
45
+ },
46
+ );
47
+ }
48
+ const files = new Map<string, Uint8Array>([["dek.toml", new Uint8Array()]]);
49
+ for (const [path, real] of sources) {
50
+ files.set(path, readFileSync(real));
51
+ }
52
+ if (license?.text !== undefined) {
53
+ files.set(license.name, new TextEncoder().encode(license.text));
54
+ }
55
+ const entries: Record<string, Uint8Array> = {};
56
+ for (const [path, bytes] of files) {
57
+ entries[`${deck.name}/${path}`] = bytes;
58
+ }
59
+ return new Bun.Archive(entries, { compress: "gzip" }).bytes();
60
+ }
61
+
62
+ /**
63
+ * The LICENSE a pack carries: the legal code `[pack] license` names, which says what the reader
64
+ * may do with a deck handed over on purpose; else the project's own LICENSE file; else none.
65
+ */
66
+ function packLicense(
67
+ root: string,
68
+ ):
69
+ | { name: string; text: string; path?: undefined }
70
+ | { name: string; path: string; text?: undefined }
71
+ | undefined {
72
+ const id = loadConfig(join(root, "dek.toml")).pack?.license;
73
+ if (id) {
74
+ return { name: "LICENSE", text: licenseText(id) };
75
+ }
76
+ const name = LICENSE_NAMES.find((file) => isFile(join(root, file), root));
77
+ return name ? { name, path: requireInside(join(root, name), root) } : undefined;
78
+ }
79
+
80
+ /** Said to the sender, who can fix it, when a pack would go out with no license. */
81
+ const NO_PACK_LICENSE =
82
+ 'the pack carries no license, so its reader is told to ask you before reusing it; add `[pack] license = "CC0-1.0"` to dek.toml to let them reuse it freely, or put a LICENSE at the project root';
83
+
84
+ /** `dekc pack`: write the deck's pack as dist/<deck>.tgz. */
85
+ export async function writePack(
86
+ project: { root: string },
87
+ deck: { name: string; dir: string },
88
+ options: DistOptions = {},
89
+ ): Promise<{ outPath: string; warnings: string[] }> {
90
+ const bytes = await packDeck(project, deck);
91
+ const outPath = distFile(project, deck, "tgz", options);
92
+ writeInside(outPath, bytes, project.root);
93
+ return { outPath, warnings: packLicense(project.root) ? [] : [NO_PACK_LICENSE] };
94
+ }
95
+
96
+ /** What `deckFiles` looks at in the deck's own folder; dist/, .cache/, and voice/ are not read. */
97
+ const DECK_TOP = new Set(["script.md", "theme.css", "slides", "assets"]);
98
+
99
+ /**
100
+ * The deck's files a ref keeps, relative to the deck, in a stable order. A file or folder whose
101
+ * name starts with a dot stays behind; a link dek would not follow anywhere else is an error.
102
+ */
103
+ function deckFiles(deckDir: string, root: string): string[] {
104
+ const found: string[] = [];
105
+ const walk = (rel: string) => {
106
+ const dir = requireInside(join(deckDir, rel), root);
107
+ for (const name of readdirSync(dir).sort()) {
108
+ if (name.startsWith(".") || (rel === "" && !DECK_TOP.has(name))) {
109
+ continue;
110
+ }
111
+ const path = rel === "" ? name : `${rel}/${name}`;
112
+ const real = requireInside(join(deckDir, path), root);
113
+ if (!existsSync(real)) {
114
+ throw new DekError(`${relative(root, join(deckDir, path))} is missing`, {
115
+ path: join(deckDir, path),
116
+ hint: "it is a symlink to nothing; remove it, or copy the file it meant in its place",
117
+ });
118
+ }
119
+ if (statSync(real).isDirectory()) {
120
+ walk(path);
121
+ } else if (keepsInDeck(path)) {
122
+ found.push(path);
123
+ }
124
+ }
125
+ };
126
+ walk("");
127
+ return found;
128
+ }
129
+
130
+ function isFile(path: string, root: string): boolean {
131
+ const real = followInside(path, root);
132
+ try {
133
+ return real !== undefined && statSync(real).isFile();
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
@@ -0,0 +1,191 @@
1
+ /// <reference lib="dom" />
2
+ // What a PDF prints of a slide: a picture of the slide as the browser drew it, with its text laid
3
+ // over it, unseen, so a reader can still select, search, and copy it. Chromium writes a blurred
4
+ // box-shadow and `background-clip: text` into a PDF as soft masks, which Apple's viewers (Preview,
5
+ // Quick Look, Safari, iOS) draw as flat boxes; a picture looks the same in every viewer.
6
+
7
+ /** The slides of the print document, one PDF page each. */
8
+ export const PDF_SLIDES = "#deck > .slide";
9
+
10
+ /**
11
+ * Leaves each slide's text where it was laid out and paints nothing else: no color, no
12
+ * background, no border, no shadow, no picture. Only paint changes, never layout, so the text lies
13
+ * where the picture under it shows it. In a layer, so that these `!important` declarations win
14
+ * over any the theme or a slide writes, whatever their selectors weigh.
15
+ */
16
+ export const PDF_TEXT_CSS = `@layer dek-pdf-text {
17
+ #deck > .slide,
18
+ #deck > .slide::before,
19
+ #deck > .slide::after,
20
+ #deck > .slide *,
21
+ #deck > .slide *::before,
22
+ #deck > .slide *::after,
23
+ #deck > .slide *::marker,
24
+ #deck > .slide *::first-letter,
25
+ #deck > .slide *::first-line {
26
+ color: transparent !important;
27
+ -webkit-text-fill-color: transparent !important;
28
+ -webkit-text-stroke-color: transparent !important;
29
+ text-decoration-color: transparent !important;
30
+ text-emphasis-color: transparent !important;
31
+ text-shadow: none !important;
32
+ border-color: transparent !important;
33
+ border-image: none !important;
34
+ outline-color: transparent !important;
35
+ column-rule-color: transparent !important;
36
+ box-shadow: none !important;
37
+ transition: none !important;
38
+ }
39
+ #deck > .slide::before,
40
+ #deck > .slide::after,
41
+ #deck > .slide *,
42
+ #deck > .slide *::before,
43
+ #deck > .slide *::after,
44
+ #deck > .slide *::first-letter,
45
+ #deck > .slide *::first-line {
46
+ background: none !important;
47
+ }
48
+ #deck > .slide {
49
+ opacity: 1 !important;
50
+ mix-blend-mode: normal !important;
51
+ }
52
+ #deck > .slide :is(img, video, canvas, iframe, embed, object, input, select, textarea, button, progress, meter),
53
+ #deck > .slide svg image {
54
+ opacity: 0 !important;
55
+ }
56
+ #deck > .slide svg * {
57
+ fill: transparent !important;
58
+ stroke: transparent !important;
59
+ }
60
+ }`;
61
+
62
+ /**
63
+ * Where each slide lies on the whole page, for its picture to be shot.
64
+ *
65
+ * Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
66
+ */
67
+ export function slideBoxesInPage(
68
+ selector: string,
69
+ ): { x: number; y: number; width: number; height: number }[] {
70
+ return [...document.querySelectorAll(selector)].map((slide) => {
71
+ const box = slide.getBoundingClientRect();
72
+ return {
73
+ x: box.left + window.scrollX,
74
+ y: box.top + window.scrollY,
75
+ width: box.width,
76
+ height: box.height,
77
+ };
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Takes every paint but the text away from the page's slides, once their pictures are shot. A
83
+ * filter or a backdrop filter would draw over the picture, or turn the text under it into pixels,
84
+ * so each goes; `will-change` keeps the box it made for its positioned descendants, so they stay
85
+ * where they were laid out.
86
+ *
87
+ * Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
88
+ */
89
+ export function keepOnlyTextInPage({ selector, css }: { selector: string; css: string }): void {
90
+ for (const slide of document.querySelectorAll(selector)) {
91
+ for (const el of [slide, ...slide.querySelectorAll("*")]) {
92
+ if (!(el instanceof HTMLElement || el instanceof SVGElement)) {
93
+ continue;
94
+ }
95
+ const style = getComputedStyle(el);
96
+ const effects = (["filter", "backdrop-filter"] as const).filter(
97
+ (property) => style.getPropertyValue(property) !== "none",
98
+ );
99
+ if (effects.length === 0) {
100
+ continue;
101
+ }
102
+ const hints = style.willChange === "auto" ? [] : [style.willChange];
103
+ for (const property of effects) {
104
+ el.style.setProperty(property, "none", "important");
105
+ }
106
+ el.style.setProperty("will-change", [...hints, ...effects].join(", "), "important");
107
+ }
108
+ }
109
+ const sheet = document.createElement("style");
110
+ sheet.textContent = css;
111
+ document.head.append(sheet);
112
+ }
113
+
114
+ /** How good a JPEG a photograph's slide is kept as: near the PNG to the eye, at under half its size. */
115
+ export const PDF_JPEG_QUALITY = 90;
116
+
117
+ /** How much of a slide its photographs must cover for the slide to be kept as a JPEG. */
118
+ const PHOTO_SHARE = 0.1;
119
+
120
+ /**
121
+ * Which of a slide's two pictures the PDF keeps: the JPEG for a slide that shows photographs,
122
+ * where it is smaller, and the PNG for the rest. Type and flat shapes keep their edges in a PNG;
123
+ * a JPEG blurs them, so a slide of them is never kept as one, however much smaller it would be.
124
+ */
125
+ export function pdfPictureType(
126
+ sizes: { png: number; jpeg: number },
127
+ photoShare: number,
128
+ ): "png" | "jpeg" {
129
+ return photoShare >= PHOTO_SHARE && sizes.jpeg < sizes.png ? "jpeg" : "png";
130
+ }
131
+
132
+ /**
133
+ * How much of each slide raster pictures cover, 0 to 1: images, video, and canvases, and
134
+ * backgrounds drawn from a picture's URL. SVG is shapes, not a photograph, and does not count.
135
+ *
136
+ * Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
137
+ */
138
+ export function photoSharesInPage(selector: string): number[] {
139
+ const vector = (url: string) => /^data:image\/svg|\.svg(?:[?#]|$)/i.test(url);
140
+ return [...document.querySelectorAll(selector)].map((slide) => {
141
+ const frame = slide.getBoundingClientRect();
142
+ let covered = 0;
143
+ for (const el of [slide, ...slide.querySelectorAll("*")]) {
144
+ const style = getComputedStyle(el);
145
+ if (style.visibility !== "visible" || style.display === "none") {
146
+ continue;
147
+ }
148
+ const raster =
149
+ el instanceof HTMLImageElement
150
+ ? !vector(el.currentSrc || el.src)
151
+ : el instanceof HTMLVideoElement ||
152
+ el instanceof HTMLCanvasElement ||
153
+ [...style.backgroundImage.matchAll(/url\("?([^")]*)"?\)/g)].some(
154
+ ([, url]) => url !== undefined && !vector(url),
155
+ );
156
+ if (!raster) {
157
+ continue;
158
+ }
159
+ const box = el.getBoundingClientRect();
160
+ const width = Math.min(box.right, frame.right) - Math.max(box.left, frame.left);
161
+ const height = Math.min(box.bottom, frame.bottom) - Math.max(box.top, frame.top);
162
+ covered += Math.max(0, width) * Math.max(0, height);
163
+ }
164
+ return Math.min(1, covered / (frame.width * frame.height));
165
+ });
166
+ }
167
+
168
+ /**
169
+ * Sets a slide's picture, a `data:` URL, under its text: the `index`th of the page's slides.
170
+ * One slide a call, so no message to the page carries more than one picture.
171
+ *
172
+ * Runs in the page, shipped by `page.evaluate`, so it references nothing outside itself.
173
+ */
174
+ export function setSlidePictureInPage({
175
+ selector,
176
+ index,
177
+ picture,
178
+ }: {
179
+ selector: string;
180
+ index: number;
181
+ picture: string;
182
+ }): void {
183
+ const slide = document.querySelectorAll(selector)[index];
184
+ if (slide instanceof HTMLElement) {
185
+ slide.style.setProperty(
186
+ "background",
187
+ `url("${picture}") 0 0 / 100% 100% no-repeat border-box`,
188
+ "important",
189
+ );
190
+ }
191
+ }
@@ -3,13 +3,18 @@ declare module "playwright" {
3
3
  export type Page = {
4
4
  goto(url: string): Promise<unknown>;
5
5
  setContent(html: string, options?: { waitUntil?: string }): Promise<void>;
6
- emulateMedia(options: { reducedMotion?: "reduce" | "no-preference" }): Promise<void>;
6
+ emulateMedia(options: {
7
+ media?: "screen" | "print";
8
+ reducedMotion?: "reduce" | "no-preference";
9
+ }): Promise<void>;
7
10
  setViewportSize(size: Viewport): Promise<void>;
8
11
  evaluate<T, A = unknown>(fn: (arg: A) => T | Promise<T>, arg?: A): Promise<T>;
9
12
  screenshot(options: {
10
13
  path?: string;
11
14
  fullPage?: boolean;
15
+ clip?: { x: number; y: number; width: number; height: number };
12
16
  type?: "png" | "jpeg";
17
+ quality?: number;
13
18
  }): Promise<Buffer>;
14
19
  addScriptTag(options: { content: string }): Promise<unknown>;
15
20
  pdf(options: {
@@ -11,6 +11,16 @@ import {
11
11
  samePlace,
12
12
  } from "./overflow.ts";
13
13
  import { visitInPages } from "./page-pool.ts";
14
+ import {
15
+ keepOnlyTextInPage,
16
+ PDF_JPEG_QUALITY,
17
+ PDF_SLIDES,
18
+ PDF_TEXT_CSS,
19
+ pdfPictureType,
20
+ photoSharesInPage,
21
+ setSlidePictureInPage,
22
+ slideBoxesInPage,
23
+ } from "./pdf-text.ts";
14
24
  import type {
15
25
  ContrastFinding,
16
26
  ContrastOrigin,
@@ -50,12 +60,13 @@ export async function runVisualRequest<R extends VisualRequest>(
50
60
  browser: Browser,
51
61
  request: R,
52
62
  ): Promise<ResponseTo<R>> {
53
- // A PPTX's pictures are shown full screen, so they are drawn at twice the slide's size. A deck is
54
- // self-contained (DEK020), so the page needs no network, and a ref's slide scripts get none.
63
+ // A PPTX's and a PDF's pictures are shown full screen, so they are drawn at twice the slide's
64
+ // size. A deck is self-contained (DEK020), so the page needs no network, and a ref's slide
65
+ // scripts get none.
55
66
  const context = await browser.newContext({
56
67
  viewport: request.viewport,
57
68
  offline: true,
58
- ...(request.kind === "pptx" ? { deviceScaleFactor: 2 } : {}),
69
+ ...(request.kind === "pptx" || request.kind === "pdf" ? { deviceScaleFactor: 2 } : {}),
59
70
  });
60
71
  try {
61
72
  return (await answer(context, request)) as ResponseTo<R>;
@@ -83,10 +94,35 @@ function answer(context: BrowserContext, request: VisualRequest) {
83
94
  }
84
95
  }
85
96
 
86
- /** The whole print document as one PDF, a page per slide. */
97
+ /**
98
+ * The whole print document as one PDF, a page per slide: each slide shot as print draws it, then
99
+ * printed as that picture with its text laid over it, unseen.
100
+ */
87
101
  async function printPdf(context: BrowserContext, request: PdfRequest): Promise<DoneResponse> {
88
102
  const page = await context.newPage();
89
103
  await page.setContent(request.html, { waitUntil: "load" });
104
+ await page.evaluate(() => document.fonts.ready.then(() => undefined));
105
+ await page.emulateMedia({ media: "print" });
106
+ // A slide that shows photographs is shot as a JPEG too, and kept as one where it is smaller.
107
+ const shares = await page.evaluate(photoSharesInPage, PDF_SLIDES);
108
+ const pictures: string[] = [];
109
+ for (const [index, clip] of (await page.evaluate(slideBoxesInPage, PDF_SLIDES)).entries()) {
110
+ const share = shares[index] ?? 0;
111
+ const png = await page.screenshot({ clip, fullPage: true });
112
+ const jpeg =
113
+ pdfPictureType({ png: png.length, jpeg: 0 }, share) === "jpeg"
114
+ ? await page.screenshot({ clip, fullPage: true, type: "jpeg", quality: PDF_JPEG_QUALITY })
115
+ : undefined;
116
+ const kept =
117
+ jpeg && pdfPictureType({ png: png.length, jpeg: jpeg.length }, share) === "jpeg"
118
+ ? { type: "jpeg", data: jpeg }
119
+ : { type: "png", data: png };
120
+ pictures.push(`data:image/${kept.type};base64,${kept.data.toString("base64")}`);
121
+ }
122
+ await page.evaluate(keepOnlyTextInPage, { selector: PDF_SLIDES, css: PDF_TEXT_CSS });
123
+ for (const [index, picture] of pictures.entries()) {
124
+ await page.evaluate(setSlidePictureInPage, { selector: PDF_SLIDES, index, picture });
125
+ }
90
126
  await page.pdf({
91
127
  path: request.pdfPath,
92
128
  width: `${request.viewport.width}px`,
@@ -55,7 +55,10 @@ export type PagesRequest = {
55
55
  sheets?: SheetSpec[];
56
56
  };
57
57
 
58
- /** One page printed to `pdfPath`, a PDF page per slide. */
58
+ /**
59
+ * One page printed to `pdfPath`, a PDF page per slide: each slide a picture at twice its size,
60
+ * with its text laid over it, unseen, to select and search.
61
+ */
59
62
  export type PdfRequest = {
60
63
  kind: "pdf";
61
64
  viewport: Viewport;
@@ -2,11 +2,13 @@ import { DekError } from "./error.ts";
2
2
 
3
3
  /**
4
4
  * A ref is someone else's deck, kept as a read-only snapshot to read as a
5
- * model. dek.toml `[refs]` pins each one to a commit; the snapshot under
6
- * `refs/owner/repo/deck` is only a copy of that commit and can be fetched again.
5
+ * model. dek.toml `[refs]` pins each one: a deck on GitHub to a commit, a pack
6
+ * to a digest of its files. The snapshot under `refs/` is only a copy of what
7
+ * the pin names, fetched or unpacked again whenever it is missing.
7
8
  * This module only names refs; `ref.ts` fetches and stores them.
8
9
  */
9
- export type RefSource = {
10
+ export type GithubRefSource = {
11
+ kind: "github";
10
12
  /** `owner/repo/deck`, the key in dek.toml `[refs]`. */
11
13
  name: string;
12
14
  owner: string;
@@ -18,13 +20,32 @@ export type RefSource = {
18
20
  path?: string;
19
21
  };
20
22
 
23
+ /** A deck handed over as a file by `dekc pack`, committed as `packs/<deck>.tgz`. */
24
+ export type FileRefSource = {
25
+ kind: "file";
26
+ /** `file/<deck>`, the key in dek.toml `[refs]`. */
27
+ name: string;
28
+ deck: string;
29
+ };
30
+
31
+ export type RefSource = GithubRefSource | FileRefSource;
32
+
33
+ /** The first segment of a pack's ref name; no GitHub owner may take it, so refs/ never overlaps. */
34
+ const PACK_OWNER = "file";
35
+
21
36
  const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/;
22
37
  const REPO_RE = /^[A-Za-z0-9._-]+$/;
23
38
  const DECK_RE = /^[^/\\\s@]+$/;
24
39
  const SHA_RE = /^[0-9a-f]{40}$/;
40
+ const DIGEST_RE = /^[0-9a-f]{64}$/;
25
41
 
26
42
  const REF_HINT =
27
- "name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1";
43
+ "name a ref as owner/repo/deck, e.g. hajimism/dek/why-dek or hajimism/dek/why-dek@v1, or a pack's as file/<deck>";
44
+
45
+ /** Where a pack's ref keeps its pack, relative to the project root: committed, never ignored. */
46
+ export function packFile(deck: string): string {
47
+ return `packs/${deck}.tgz`;
48
+ }
28
49
 
29
50
  /** True for `owner/repo/deck[@rev]` or a GitHub link, which a deck name never is. */
30
51
  export function isRefName(arg: string): boolean {
@@ -34,14 +55,15 @@ export function isRefName(arg: string): boolean {
34
55
  if (arg.startsWith(".") || arg.startsWith("/") || arg.startsWith("~")) {
35
56
  return false;
36
57
  }
37
- return arg.split("@")[0]?.split("/").length === 3;
58
+ const segments = arg.split("@")[0]?.split("/") ?? [];
59
+ return segments.length === 3 || (segments.length === 2 && segments[0] === PACK_OWNER);
38
60
  }
39
61
 
40
- /** A valid `owner/repo/deck` with no rev: the form dek.toml `[refs]` keys take. */
62
+ /** A valid `owner/repo/deck` with no rev, or a pack's `file/<deck>`: the form dek.toml `[refs]` keys take. */
41
63
  export function isPlainRefName(name: string): boolean {
42
64
  try {
43
65
  const source = parseRefSource(name);
44
- return source.rev === undefined && source.name === name;
66
+ return (source.kind === "file" || source.rev === undefined) && source.name === name;
45
67
  } catch {
46
68
  return false;
47
69
  }
@@ -51,6 +73,16 @@ export function isPinnedRev(rev: string): boolean {
51
73
  return SHA_RE.test(rev);
52
74
  }
53
75
 
76
+ /** True when `pin` has the form a ref of this kind is pinned with: a commit, or a pack's digest. */
77
+ export function isPinFor(source: RefSource, pin: string): boolean {
78
+ return source.kind === "file" ? DIGEST_RE.test(pin) : SHA_RE.test(pin);
79
+ }
80
+
81
+ /** True for a deck name a ref can carry: no slash, space, or @, and not . or .. */
82
+ export function isRefDeckName(deck: string | undefined): deck is string {
83
+ return deck !== undefined && DECK_RE.test(deck) && deck !== "." && deck !== "..";
84
+ }
85
+
54
86
  export function parseRefSource(input: string): RefSource {
55
87
  const trimmed = input.trim();
56
88
  if (/^https?:\/\//i.test(trimmed)) {
@@ -60,6 +92,17 @@ export function parseRefSource(input: string): RefSource {
60
92
  const path = at === -1 ? trimmed : trimmed.slice(0, at);
61
93
  const rev = at === -1 ? undefined : trimmed.slice(at + 1);
62
94
  const [owner, repo, deck, ...extra] = path.split("/");
95
+ if (owner === PACK_OWNER && deck === undefined) {
96
+ if (rev !== undefined || !isRefDeckName(repo)) {
97
+ throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
98
+ }
99
+ return { kind: "file", name: `${PACK_OWNER}/${repo}`, deck: repo };
100
+ }
101
+ if (owner?.toLowerCase() === PACK_OWNER && deck !== undefined) {
102
+ throw new DekError(`"${input}" is not a ref name`, {
103
+ hint: "file is kept for packs, so no GitHub owner of that name can be pinned; name a pack's ref as file/<deck>",
104
+ });
105
+ }
63
106
  const valid =
64
107
  extra.length === 0 &&
65
108
  owner !== undefined &&
@@ -68,15 +111,13 @@ export function parseRefSource(input: string): RefSource {
68
111
  REPO_RE.test(repo) &&
69
112
  repo !== "." &&
70
113
  repo !== ".." &&
71
- deck !== undefined &&
72
- DECK_RE.test(deck) &&
73
- deck !== "." &&
74
- deck !== ".." &&
114
+ isRefDeckName(deck) &&
75
115
  (rev === undefined || /^[^\s@]+$/.test(rev));
76
116
  if (!valid) {
77
117
  throw new DekError(`"${input}" is not a ref name`, { hint: REF_HINT });
78
118
  }
79
119
  return {
120
+ kind: "github",
80
121
  name: `${owner}/${repo}/${deck}`,
81
122
  owner,
82
123
  repo,
@@ -90,7 +131,7 @@ export function parseRefSource(input: string): RefSource {
90
131
  * link people copy from a browser. A branch name with a slash cannot be told
91
132
  * apart from the path, so such a branch goes after `@` instead.
92
133
  */
93
- function parseGithubUrl(input: string): RefSource {
134
+ function parseGithubUrl(input: string): GithubRefSource {
94
135
  const url = new URL(input);
95
136
  const [owner = "", repo = "", kind, rev, ...rest] = url.pathname.split("/").filter(Boolean);
96
137
  const decks = rest.indexOf("decks");
@@ -106,5 +147,8 @@ function parseGithubUrl(input: string): RefSource {
106
147
  });
107
148
  }
108
149
  const source = parseRefSource(`${owner}/${repo.replace(/\.git$/, "")}/${deck}@${rev}`);
150
+ if (source.kind !== "github") {
151
+ throw new DekError(`"${input}" is not a link to a deck on GitHub`, { hint: REF_HINT });
152
+ }
109
153
  return { ...source, path: rest.slice(0, decks + 2).join("/") };
110
154
  }