@twango/twiki 0.0.0-stage → 0.1.0-rc.0
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.
- package/.github/actions/product-cache/action.yml +32 -0
- package/.github/actions/product-cache/index.mjs +450 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bun.lock +1701 -0
- package/cli/runtime.test.ts +420 -0
- package/cli/runtime.ts +385 -0
- package/cli/twiki.ts +305 -0
- package/package.json +138 -4
- package/site/_headers +7 -0
- package/site/assets/fonts/OFL.txt +93 -0
- package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
- package/site/assets/social-background.svg +38 -0
- package/site/bun.lock +1701 -0
- package/site/database/index.ts +7 -0
- package/site/database/schema.ts +95 -0
- package/site/drizzle.config.ts +7 -0
- package/site/migrations/0001_auth.sql +14 -0
- package/site/migrations/0002_site_access.sql +5 -0
- package/site/migrations/0003_github_username.sql +1 -0
- package/site/migrations/meta/0002_snapshot.json +432 -0
- package/site/migrations/meta/0003_snapshot.json +439 -0
- package/site/migrations/meta/_journal.json +27 -0
- package/site/package.json +120 -0
- package/site/src/app.d.ts +14 -0
- package/site/src/app.html +11 -0
- package/site/src/hooks.server.ts +12 -0
- package/site/src/lib/analytics-config.json +1 -0
- package/site/src/lib/analytics.ts +40 -0
- package/site/src/lib/auth-config.json +3 -0
- package/site/src/lib/components/SEO.svelte +113 -0
- package/site/src/lib/components/doc/AccessLink.svelte +27 -0
- package/site/src/lib/components/doc/Backlinks.svelte +59 -0
- package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
- package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
- package/site/src/lib/components/doc/DocPager.svelte +91 -0
- package/site/src/lib/components/doc/DocShell.svelte +115 -0
- package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
- package/site/src/lib/components/doc/LockedContent.svelte +29 -0
- package/site/src/lib/components/doc/NavTree.svelte +158 -0
- package/site/src/lib/components/doc/NoteActions.svelte +139 -0
- package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
- package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
- package/site/src/lib/components/doc/Sidebar.svelte +351 -0
- package/site/src/lib/components/doc/Toc.svelte +339 -0
- package/site/src/lib/components/doc/enhancements.ts +146 -0
- package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
- package/site/src/lib/components/doc/image-preview.ts +114 -0
- package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
- package/site/src/lib/components/embeds/mermaid.ts +353 -0
- package/site/src/lib/components/files/FileCode.svelte +81 -0
- package/site/src/lib/components/files/FileExplorer.svelte +74 -0
- package/site/src/lib/components/files/FileHistory.svelte +170 -0
- package/site/src/lib/components/files/FileIcon.svelte +28 -0
- package/site/src/lib/components/files/FileRmd.svelte +209 -0
- package/site/src/lib/components/files/FileTabs.svelte +180 -0
- package/site/src/lib/components/files/FileTree.svelte +67 -0
- package/site/src/lib/components/files/blame-gutter.ts +45 -0
- package/site/src/lib/components/files/code-view.ts +98 -0
- package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
- package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
- package/site/src/lib/components/graph/GraphView.svelte +100 -0
- package/site/src/lib/components/graph/graph-data.ts +137 -0
- package/site/src/lib/components/graph/graph-model.ts +103 -0
- package/site/src/lib/components/graph/index.ts +3 -0
- package/site/src/lib/components/graph/render-graph.ts +626 -0
- package/site/src/lib/components/popover/index.ts +1 -0
- package/site/src/lib/components/popover/link-popovers.ts +233 -0
- package/site/src/lib/components/popover/popover.css +82 -0
- package/site/src/lib/components/search/SearchButton.svelte +57 -0
- package/site/src/lib/components/search/SearchPalette.svelte +189 -0
- package/site/src/lib/components/search/index.ts +3 -0
- package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
- package/site/src/lib/components/search/pagefind-query.ts +22 -0
- package/site/src/lib/components/search/pagefind-url.ts +12 -0
- package/site/src/lib/components/search/search-state.svelte.ts +22 -0
- package/site/src/lib/components/ui/IconButton.svelte +30 -0
- package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
- package/site/src/lib/components/ui/index.ts +1 -0
- package/site/src/lib/config.ts +9 -0
- package/site/src/lib/content-license.ts +7 -0
- package/site/src/lib/file-history.ts +37 -0
- package/site/src/lib/file-icons.ts +27 -0
- package/site/src/lib/file-tabs.ts +72 -0
- package/site/src/lib/files.ts +73 -0
- package/site/src/lib/github-user.ts +70 -0
- package/site/src/lib/metadata.ts +166 -0
- package/site/src/lib/notebook-nav.ts +63 -0
- package/site/src/lib/publication.ts +1 -0
- package/site/src/lib/rmd.ts +156 -0
- package/site/src/lib/server/access-admin.ts +33 -0
- package/site/src/lib/server/content-model.ts +253 -0
- package/site/src/lib/server/content-provider.ts +266 -0
- package/site/src/lib/server/content.ts +277 -0
- package/site/src/lib/server/runtime-content.ts +34 -0
- package/site/src/lib/site-config.json +9 -0
- package/site/src/lib/social-image.ts +13 -0
- package/site/src/lib/styles/article.css +607 -0
- package/site/src/lib/styles/foundation.css +138 -0
- package/site/src/lib/styles/print.css +115 -0
- package/site/src/lib/styles/shell.css +182 -0
- package/site/src/lib/text-exports.ts +5 -0
- package/site/src/lib/types.ts +140 -0
- package/site/src/lib/utils.ts +6 -0
- package/site/src/routes/+error.svelte +16 -0
- package/site/src/routes/+layout.server.ts +10 -0
- package/site/src/routes/+layout.svelte +72 -0
- package/site/src/routes/+layout.ts +3 -0
- package/site/src/routes/404/+page.svelte +13 -0
- package/site/src/routes/[...slug]/+page.server.ts +24 -0
- package/site/src/routes/[...slug]/+page.svelte +180 -0
- package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
- package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
- package/site/src/routes/admin/access/+page.server.ts +53 -0
- package/site/src/routes/admin/access/+page.svelte +118 -0
- package/site/src/routes/graph.json/+server.ts +7 -0
- package/site/src/routes/index.xml/+server.ts +7 -0
- package/site/src/routes/layout.css +10 -0
- package/site/src/routes/robots.txt/+server.ts +9 -0
- package/site/src/routes/sitemap.xml/+server.ts +7 -0
- package/site/src/routes/tags/+page.server.ts +5 -0
- package/site/src/routes/tags/+page.svelte +38 -0
- package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
- package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
- package/site/static/favicon.png +0 -0
- package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
- package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
- package/site/static/fonts/Virgil.woff2 +0 -0
- package/site/svelte.config.js +71 -0
- package/site/tooling/LICENSE-quartz +25 -0
- package/site/tooling/adapter.js +55 -0
- package/site/tooling/assets.ts +323 -0
- package/site/tooling/benchmark.ts +191 -0
- package/site/tooling/build.ts +407 -0
- package/site/tooling/cache.ts +133 -0
- package/site/tooling/check-diagrams.ts +164 -0
- package/site/tooling/check-links.ts +86 -0
- package/site/tooling/check-targets.ts +139 -0
- package/site/tooling/compiler.ts +1386 -0
- package/site/tooling/deploy.ts +112 -0
- package/site/tooling/fingerprint.ts +81 -0
- package/site/tooling/lib/app-version.js +92 -0
- package/site/tooling/lib/application-asset-urls.ts +27 -0
- package/site/tooling/lib/application-cache.ts +269 -0
- package/site/tooling/lib/asset-hash-cache.ts +116 -0
- package/site/tooling/lib/autotag.ts +99 -0
- package/site/tooling/lib/build-assembly.ts +148 -0
- package/site/tooling/lib/build-workspace.ts +386 -0
- package/site/tooling/lib/cache-selection.ts +70 -0
- package/site/tooling/lib/cloudflare-prewarm.js +40 -0
- package/site/tooling/lib/comments.ts +32 -0
- package/site/tooling/lib/content-cache.ts +244 -0
- package/site/tooling/lib/content-urls.ts +50 -0
- package/site/tooling/lib/course-cache.ts +298 -0
- package/site/tooling/lib/course-files.ts +281 -0
- package/site/tooling/lib/currency.ts +33 -0
- package/site/tooling/lib/deployment-config.ts +222 -0
- package/site/tooling/lib/deployment-manifest.ts +274 -0
- package/site/tooling/lib/deployment-prepare.ts +325 -0
- package/site/tooling/lib/deployment-upload.ts +293 -0
- package/site/tooling/lib/dev-git-state.ts +130 -0
- package/site/tooling/lib/dev-history.ts +86 -0
- package/site/tooling/lib/dev-social-images.ts +38 -0
- package/site/tooling/lib/dev-state.ts +107 -0
- package/site/tooling/lib/edition-builds.ts +184 -0
- package/site/tooling/lib/edition-paths.js +29 -0
- package/site/tooling/lib/empty-tag-route.js +23 -0
- package/site/tooling/lib/file-history.ts +201 -0
- package/site/tooling/lib/file-icons.ts +93 -0
- package/site/tooling/lib/file-policy.ts +38 -0
- package/site/tooling/lib/file-search.js +13 -0
- package/site/tooling/lib/git-revisions.ts +41 -0
- package/site/tooling/lib/history-pool.ts +74 -0
- package/site/tooling/lib/history-worker.ts +22 -0
- package/site/tooling/lib/knit-rmd.R +38 -0
- package/site/tooling/lib/lastmod.ts +201 -0
- package/site/tooling/lib/markdown-files.ts +51 -0
- package/site/tooling/lib/ofm.ts +634 -0
- package/site/tooling/lib/output.ts +60 -0
- package/site/tooling/lib/product-cache.ts +1006 -0
- package/site/tooling/lib/project-config.ts +288 -0
- package/site/tooling/lib/public-assets.ts +38 -0
- package/site/tooling/lib/public-links.ts +56 -0
- package/site/tooling/lib/publishing.ts +156 -0
- package/site/tooling/lib/r-runtime.ts +257 -0
- package/site/tooling/lib/rebuild-queue.ts +25 -0
- package/site/tooling/lib/rmd-preflight.ts +166 -0
- package/site/tooling/lib/rmd-preview-cache.ts +93 -0
- package/site/tooling/lib/rmd-previews.ts +129 -0
- package/site/tooling/lib/runtime-data-modules.ts +30 -0
- package/site/tooling/lib/runtime-packaging.ts +321 -0
- package/site/tooling/lib/search-cache.js +165 -0
- package/site/tooling/lib/search-inputs.js +129 -0
- package/site/tooling/lib/slug.ts +377 -0
- package/site/tooling/lib/social-images.ts +210 -0
- package/site/tooling/lib/text-exports.ts +51 -0
- package/site/tooling/lib/visit-elements.ts +17 -0
- package/site/tooling/live-test.ts +138 -0
- package/site/tooling/pipeline.ts +103 -0
- package/site/tooling/prepare.ts +3 -0
- package/site/tooling/publish.ts +28 -0
- package/site/tooling/r-cache.ts +45 -0
- package/site/tooling/r-render-profile.json +7 -0
- package/site/tooling/search.js +94 -0
- package/site/tooling/verify-deployment.ts +109 -0
- package/site/tooling/vite-content.ts +240 -0
- package/site/tooling/wrangler-paths.js +25 -0
- package/site/tooling/wrangler.ts +19 -0
- package/site/tsconfig.json +16 -0
- package/site/vite.config.ts +40 -0
- package/site/worker/access.ts +57 -0
- package/site/worker/analytics.ts +100 -0
- package/site/worker/auth-routes.ts +78 -0
- package/site/worker/auth.ts +78 -0
- package/site/worker/content-runtime.ts +226 -0
- package/site/worker/framing.ts +4 -0
- package/site/worker/gate.ts +147 -0
- package/site/worker/index.ts +9 -0
- package/site/worker/login.ts +62 -0
- package/site/worker/publication.ts +44 -0
- package/site/worker/runtime.ts +104 -0
- package/site/worker/tsconfig.json +13 -0
- package/site/wrangler.jsonc +37 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL and wikilink utilities ported from the Wisconsin Quartz fork's path.ts.
|
|
3
|
+
* Ambiguous links prefer the longest shared directory prefix, then the
|
|
4
|
+
* shallowest path, then lexicographic order.
|
|
5
|
+
*/
|
|
6
|
+
import { slug as slugAnchor } from 'github-slugger';
|
|
7
|
+
import type { Element as HastElement } from 'hast';
|
|
8
|
+
|
|
9
|
+
const clone = <T>(x: T): T => structuredClone(x);
|
|
10
|
+
|
|
11
|
+
/// Utility type to simulate nominal types in TypeScript
|
|
12
|
+
type SlugLike<T> = string & { __brand: T };
|
|
13
|
+
|
|
14
|
+
/** Cannot be relative and must have a file extension. */
|
|
15
|
+
export type FilePath = SlugLike<'filepath'>;
|
|
16
|
+
export function isFilePath(s: string): s is FilePath {
|
|
17
|
+
const validStart = !s.startsWith('.');
|
|
18
|
+
return validStart && _hasFileExtension(s);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Cannot be relative and may not have leading or trailing slashes. It can have `index` as it's last segment. Use this wherever possible is it's the most 'general' interpretation of a slug. */
|
|
22
|
+
export type FullSlug = SlugLike<'full'>;
|
|
23
|
+
export function isFullSlug(s: string): s is FullSlug {
|
|
24
|
+
const validStart = !(s.startsWith('.') || s.startsWith('/'));
|
|
25
|
+
const validEnding = !s.endsWith('/');
|
|
26
|
+
return validStart && validEnding && !containsForbiddenCharacters(s);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Shouldn't be a relative path and shouldn't have `/index` as an ending or a file extension. It _can_ however have a trailing slash to indicate a folder path. */
|
|
30
|
+
export type SimpleSlug = SlugLike<'simple'>;
|
|
31
|
+
export function isSimpleSlug(s: string): s is SimpleSlug {
|
|
32
|
+
const validStart = !(s.startsWith('.') || (s.length > 1 && s.startsWith('/')));
|
|
33
|
+
const validEnding = !endsWith(s, 'index');
|
|
34
|
+
return validStart && !containsForbiddenCharacters(s) && validEnding && !_hasFileExtension(s);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Can be found on `href`s but can also be constructed for client-side navigation (e.g. search and graph) */
|
|
38
|
+
export type RelativeURL = SlugLike<'relative'>;
|
|
39
|
+
export function isRelativeURL(s: string): s is RelativeURL {
|
|
40
|
+
const validStart = /^\.{1,2}/.test(s);
|
|
41
|
+
const validEnding = !endsWith(s, 'index');
|
|
42
|
+
return validStart && validEnding && !['.md', '.html'].includes(getFileExtension(s) ?? '');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isAbsoluteURL(s: string): boolean {
|
|
46
|
+
try {
|
|
47
|
+
new URL(s);
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function sluggify(s: string): string {
|
|
55
|
+
return s
|
|
56
|
+
.split('/')
|
|
57
|
+
.map((segment) =>
|
|
58
|
+
segment
|
|
59
|
+
.replace(/\s/g, '-')
|
|
60
|
+
.replace(/&/g, '-and-')
|
|
61
|
+
.replace(/%/g, '-percent')
|
|
62
|
+
.replace(/\?/g, '')
|
|
63
|
+
.replace(/#/g, '')
|
|
64
|
+
)
|
|
65
|
+
.join('/') // always use / as sep
|
|
66
|
+
.replace(/\/$/, '');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function slugifyFilePath(fp: FilePath, excludeExt?: boolean): FullSlug {
|
|
70
|
+
fp = stripSlashes(fp) as FilePath;
|
|
71
|
+
let ext = getFileExtension(fp);
|
|
72
|
+
const withoutFileExt = fp.replace(new RegExp(ext + '$'), '');
|
|
73
|
+
if (excludeExt || ['.md', '.html', undefined].includes(ext)) {
|
|
74
|
+
ext = '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let slug = sluggify(withoutFileExt);
|
|
78
|
+
|
|
79
|
+
// treat _index as index
|
|
80
|
+
if (endsWith(slug, '_index')) {
|
|
81
|
+
slug = slug.replace(/_index$/, 'index');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return (slug + ext) as FullSlug;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function simplifySlug(fp: FullSlug): SimpleSlug {
|
|
88
|
+
const res = stripSlashes(trimSuffix(fp, 'index'), true);
|
|
89
|
+
return (res.length === 0 ? '/' : res) as SimpleSlug;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function transformInternalLink(link: string): RelativeURL {
|
|
93
|
+
let [fplike, anchor] = splitAnchor(decodeURI(link));
|
|
94
|
+
|
|
95
|
+
const folderPath = isFolderPath(fplike);
|
|
96
|
+
let segments = fplike.split('/').filter((x) => x.length > 0);
|
|
97
|
+
let prefix = segments.filter(isRelativeSegment).join('/');
|
|
98
|
+
let fp = segments.filter((seg) => !isRelativeSegment(seg) && seg !== '').join('/');
|
|
99
|
+
|
|
100
|
+
// manually add ext here as we want to not strip 'index' if it has an extension
|
|
101
|
+
const simpleSlug = simplifySlug(slugifyFilePath(fp as FilePath));
|
|
102
|
+
const joined = joinSegments(stripSlashes(prefix), stripSlashes(simpleSlug));
|
|
103
|
+
const trail = folderPath ? '/' : '';
|
|
104
|
+
const res = (_addRelativeToStart(joined) + trail + anchor) as RelativeURL;
|
|
105
|
+
return res;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const _rebaseHastElement = (
|
|
109
|
+
el: HastElement,
|
|
110
|
+
attr: string,
|
|
111
|
+
curBase: FullSlug,
|
|
112
|
+
newBase: FullSlug
|
|
113
|
+
) => {
|
|
114
|
+
if (el.properties?.[attr]) {
|
|
115
|
+
if (!isRelativeURL(String(el.properties[attr]))) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rel = joinSegments(
|
|
120
|
+
resolveRelative(curBase, newBase),
|
|
121
|
+
'..',
|
|
122
|
+
el.properties[attr] as string
|
|
123
|
+
);
|
|
124
|
+
el.properties[attr] = rel;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export function normalizeHastElement(rawEl: HastElement, curBase: FullSlug, newBase: FullSlug) {
|
|
129
|
+
const el = clone(rawEl); // clone so we dont modify the original page
|
|
130
|
+
_rebaseHastElement(el, 'src', curBase, newBase);
|
|
131
|
+
_rebaseHastElement(el, 'href', curBase, newBase);
|
|
132
|
+
if (el.children) {
|
|
133
|
+
el.children = el.children.map((child) =>
|
|
134
|
+
normalizeHastElement(child as HastElement, curBase, newBase)
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return el;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// resolve /a/b/c to ../..
|
|
142
|
+
export function pathToRoot(slug: FullSlug): RelativeURL {
|
|
143
|
+
let rootPath = slug
|
|
144
|
+
.split('/')
|
|
145
|
+
.filter((x) => x !== '')
|
|
146
|
+
.slice(0, -1)
|
|
147
|
+
.map((_) => '..')
|
|
148
|
+
.join('/');
|
|
149
|
+
|
|
150
|
+
if (rootPath.length === 0) {
|
|
151
|
+
rootPath = '.';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return rootPath as RelativeURL;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resolveRelative(current: FullSlug, target: FullSlug | SimpleSlug): RelativeURL {
|
|
158
|
+
const res = joinSegments(pathToRoot(current), simplifySlug(target as FullSlug)) as RelativeURL;
|
|
159
|
+
return res;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function splitAnchor(link: string): [string, string] {
|
|
163
|
+
let [fp, anchor] = link.split('#', 2);
|
|
164
|
+
if (fp.endsWith('.pdf')) {
|
|
165
|
+
return [fp, anchor === undefined ? '' : `#${anchor}`];
|
|
166
|
+
}
|
|
167
|
+
anchor = anchor === undefined ? '' : '#' + slugAnchor(anchor);
|
|
168
|
+
return [fp, anchor];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function slugTag(tag: string) {
|
|
172
|
+
return tag
|
|
173
|
+
.split('/')
|
|
174
|
+
.map((tagSegment) => sluggify(tagSegment))
|
|
175
|
+
.join('/');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function joinSegments(...args: string[]): string {
|
|
179
|
+
if (args.length === 0) {
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let joined = args
|
|
184
|
+
.filter((segment) => segment !== '' && segment !== '/')
|
|
185
|
+
.map((segment) => stripSlashes(segment))
|
|
186
|
+
.join('/');
|
|
187
|
+
|
|
188
|
+
// if the first segment starts with a slash, add it back
|
|
189
|
+
if (args[0].startsWith('/')) {
|
|
190
|
+
joined = '/' + joined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// if the last segment is a folder, add a trailing slash
|
|
194
|
+
if (args[args.length - 1].endsWith('/')) {
|
|
195
|
+
joined = joined + '/';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return joined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function getAllSegmentPrefixes(tags: string): string[] {
|
|
202
|
+
const segments = tags.split('/');
|
|
203
|
+
const results: string[] = [];
|
|
204
|
+
for (let i = 0; i < segments.length; i++) {
|
|
205
|
+
results.push(segments.slice(0, i + 1).join('/'));
|
|
206
|
+
}
|
|
207
|
+
return results;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface TransformOptions {
|
|
211
|
+
strategy: 'absolute' | 'relative' | 'shortest';
|
|
212
|
+
allSlugs: readonly FullSlug[];
|
|
213
|
+
slugIndex?: {
|
|
214
|
+
files: ReadonlyMap<string, readonly FullSlug[]>;
|
|
215
|
+
folders: ReadonlyMap<string, readonly FullSlug[]>;
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Index each path suffix once for a corpus snapshot, preserving candidate order.
|
|
220
|
+
* Create new options when the corpus changes; no index survives between builds.
|
|
221
|
+
*/
|
|
222
|
+
export function createTransformOptions(
|
|
223
|
+
allSlugs: readonly FullSlug[],
|
|
224
|
+
strategy: TransformOptions['strategy'] = 'shortest'
|
|
225
|
+
): TransformOptions {
|
|
226
|
+
const options: TransformOptions = { strategy, allSlugs: Object.freeze([...allSlugs]) };
|
|
227
|
+
if (strategy !== 'shortest') return options;
|
|
228
|
+
const files = new Map<string, FullSlug[]>();
|
|
229
|
+
const folders = new Map<string, FullSlug[]>();
|
|
230
|
+
const add = (index: Map<string, FullSlug[]>, candidate: string, slug: FullSlug) => {
|
|
231
|
+
while (true) {
|
|
232
|
+
const matches = index.get(candidate);
|
|
233
|
+
if (matches) matches.push(slug);
|
|
234
|
+
else index.set(candidate, [slug]);
|
|
235
|
+
const slash = candidate.indexOf('/');
|
|
236
|
+
if (slash < 0) break;
|
|
237
|
+
candidate = candidate.slice(slash + 1);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
for (const slug of options.allSlugs) {
|
|
241
|
+
add(files, slug, slug);
|
|
242
|
+
add(folders, stripSlashes(simplifySlug(slug)), slug);
|
|
243
|
+
}
|
|
244
|
+
options.slugIndex = { files, folders };
|
|
245
|
+
return options;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function transformLink(src: FullSlug, target: string, opts: TransformOptions): RelativeURL {
|
|
249
|
+
// Explicit relative links are relative to the source note, not the vault root.
|
|
250
|
+
if (opts.strategy !== 'relative' && /^\.\.?\//.test(target)) {
|
|
251
|
+
const [relativePath, anchor] = splitAnchor(decodeURI(target));
|
|
252
|
+
const parts = src.split('/').slice(0, -1);
|
|
253
|
+
for (const part of relativePath.split('/')) {
|
|
254
|
+
if (part === '..') parts.pop();
|
|
255
|
+
else if (part && part !== '.') parts.push(part);
|
|
256
|
+
}
|
|
257
|
+
const resolved = slugifyFilePath(parts.join('/') as FilePath);
|
|
258
|
+
return (resolveRelative(src, resolved) + anchor) as RelativeURL;
|
|
259
|
+
}
|
|
260
|
+
let targetSlug = transformInternalLink(target);
|
|
261
|
+
|
|
262
|
+
if (opts.strategy === 'relative') {
|
|
263
|
+
return targetSlug as RelativeURL;
|
|
264
|
+
} else {
|
|
265
|
+
const folderTail = isFolderPath(splitAnchor(targetSlug)[0]) ? '/' : '';
|
|
266
|
+
const canonicalSlug = stripSlashes(targetSlug.slice('.'.length));
|
|
267
|
+
let [targetCanonical, targetAnchor] = splitAnchor(canonicalSlug);
|
|
268
|
+
targetCanonical = stripSlashes(targetCanonical);
|
|
269
|
+
|
|
270
|
+
if (opts.strategy === 'shortest') {
|
|
271
|
+
// match by full path suffix on a "/" boundary; for basename-only targets
|
|
272
|
+
// this is equivalent to matching the last path segment, and for
|
|
273
|
+
// path-prefix wikilinks like [[folder/file]] it finds slugs whose path
|
|
274
|
+
// ends in folder/file (mirrors Obsidian's shortest-path resolution).
|
|
275
|
+
const matchingFileNames = opts.slugIndex
|
|
276
|
+
? ((folderTail ? opts.slugIndex.folders : opts.slugIndex.files).get(targetCanonical) ?? [])
|
|
277
|
+
: opts.allSlugs.filter((slug) => {
|
|
278
|
+
const candidate = folderTail ? stripSlashes(simplifySlug(slug)) : slug;
|
|
279
|
+
return candidate === targetCanonical || candidate.endsWith('/' + targetCanonical);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// only match, just use it
|
|
283
|
+
if (matchingFileNames.length === 1) {
|
|
284
|
+
const targetSlug = matchingFileNames[0];
|
|
285
|
+
return (resolveRelative(src, targetSlug) + targetAnchor) as RelativeURL;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// multiple matches: mirror Obsidian's "nearest" resolution.
|
|
289
|
+
// Prefer the candidate with the longest shared directory prefix to src;
|
|
290
|
+
// break ties by shallowest path, then lexicographically for stability.
|
|
291
|
+
if (matchingFileNames.length > 1) {
|
|
292
|
+
const srcDir = src.split('/').slice(0, -1);
|
|
293
|
+
const best = matchingFileNames
|
|
294
|
+
.map((slug) => {
|
|
295
|
+
const dir = slug.split('/').slice(0, -1);
|
|
296
|
+
let shared = 0;
|
|
297
|
+
while (
|
|
298
|
+
shared < srcDir.length &&
|
|
299
|
+
shared < dir.length &&
|
|
300
|
+
srcDir[shared] === dir[shared]
|
|
301
|
+
) {
|
|
302
|
+
shared++;
|
|
303
|
+
}
|
|
304
|
+
return { slug, shared, depth: dir.length };
|
|
305
|
+
})
|
|
306
|
+
.sort(
|
|
307
|
+
(a, b) => b.shared - a.shared || a.depth - b.depth || a.slug.localeCompare(b.slug)
|
|
308
|
+
)[0];
|
|
309
|
+
return (resolveRelative(src, best.slug) + targetAnchor) as RelativeURL;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// if it's not unique, then it's the absolute path from the vault root
|
|
314
|
+
return (joinSegments(pathToRoot(src), canonicalSlug) + folderTail) as RelativeURL;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// path helpers
|
|
319
|
+
export function isFolderPath(fplike: string): boolean {
|
|
320
|
+
return (
|
|
321
|
+
fplike.endsWith('/') ||
|
|
322
|
+
endsWith(fplike, 'index') ||
|
|
323
|
+
endsWith(fplike, 'index.md') ||
|
|
324
|
+
endsWith(fplike, 'index.html')
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function endsWith(s: string, suffix: string): boolean {
|
|
329
|
+
return s === suffix || s.endsWith('/' + suffix);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function trimSuffix(s: string, suffix: string): string {
|
|
333
|
+
if (endsWith(s, suffix)) {
|
|
334
|
+
s = s.slice(0, -suffix.length);
|
|
335
|
+
}
|
|
336
|
+
return s;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function containsForbiddenCharacters(s: string): boolean {
|
|
340
|
+
return s.includes(' ') || s.includes('#') || s.includes('?') || s.includes('&');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function _hasFileExtension(s: string): boolean {
|
|
344
|
+
return getFileExtension(s) !== undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function getFileExtension(s: string): string | undefined {
|
|
348
|
+
return s.match(/\.[A-Za-z0-9]+$/)?.[0];
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function isRelativeSegment(s: string): boolean {
|
|
352
|
+
return /^\.{0,2}$/.test(s);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function stripSlashes(s: string, onlyStripPrefix?: boolean): string {
|
|
356
|
+
if (s.startsWith('/')) {
|
|
357
|
+
s = s.substring(1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!onlyStripPrefix && s.endsWith('/')) {
|
|
361
|
+
s = s.slice(0, -1);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return s;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function _addRelativeToStart(s: string): string {
|
|
368
|
+
if (s === '') {
|
|
369
|
+
s = '.';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!s.startsWith('.')) {
|
|
373
|
+
s = joinSegments('.', s);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return s;
|
|
377
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import satori from 'satori';
|
|
2
|
+
import { stageFingerprint } from '../fingerprint';
|
|
3
|
+
import { Resvg } from '@resvg/resvg-js';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import {
|
|
6
|
+
copyFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { staticDirectory } from './edition-paths.js';
|
|
16
|
+
import type { ContentManifest, ManifestPage } from '../../src/lib/types';
|
|
17
|
+
import { site } from '../../src/lib/config';
|
|
18
|
+
import { socialImagePath, socialImageSize } from '../../src/lib/social-image';
|
|
19
|
+
import { writeChanged } from './output';
|
|
20
|
+
|
|
21
|
+
const fontPath = path.resolve(
|
|
22
|
+
import.meta.dirname,
|
|
23
|
+
'../../assets/fonts/OverusedGrotesk-SemiBold.ttf'
|
|
24
|
+
);
|
|
25
|
+
const font = readFileSync(fontPath);
|
|
26
|
+
const background = readFileSync(
|
|
27
|
+
path.resolve(import.meta.dirname, '../../assets/social-background.svg')
|
|
28
|
+
);
|
|
29
|
+
const backgroundImage = `data:image/svg+xml;base64,${background.toString('base64')}`;
|
|
30
|
+
const rendererKey = await stageFingerprint('social');
|
|
31
|
+
|
|
32
|
+
interface SocialCard {
|
|
33
|
+
title: string;
|
|
34
|
+
subtitle?: string;
|
|
35
|
+
label: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function shorten(text: string, length: number) {
|
|
39
|
+
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
40
|
+
return normalized.length <= length ? normalized : normalized.slice(0, length - 1).trimEnd() + '…';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function renderSocialImage(card: SocialCard) {
|
|
44
|
+
const title = shorten(card.title, 200);
|
|
45
|
+
const svg = await satori(
|
|
46
|
+
{
|
|
47
|
+
type: 'div',
|
|
48
|
+
props: {
|
|
49
|
+
style: {
|
|
50
|
+
position: 'relative',
|
|
51
|
+
overflow: 'hidden',
|
|
52
|
+
display: 'flex',
|
|
53
|
+
flexDirection: 'column',
|
|
54
|
+
width: '100%',
|
|
55
|
+
height: '100%',
|
|
56
|
+
background: '#1a1916',
|
|
57
|
+
color: '#e8e5df',
|
|
58
|
+
padding: '64px 72px',
|
|
59
|
+
fontFamily: 'Overused Grotesk',
|
|
60
|
+
fontWeight: 600
|
|
61
|
+
},
|
|
62
|
+
children: [
|
|
63
|
+
{
|
|
64
|
+
type: 'img',
|
|
65
|
+
props: {
|
|
66
|
+
src: backgroundImage,
|
|
67
|
+
width: socialImageSize.width,
|
|
68
|
+
height: socialImageSize.height,
|
|
69
|
+
style: { position: 'absolute', top: 0, left: 0 }
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
type: 'div',
|
|
74
|
+
props: {
|
|
75
|
+
style: { color: '#e68578', fontSize: 24 },
|
|
76
|
+
children: shorten(card.label, 70)
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: 'div',
|
|
81
|
+
props: {
|
|
82
|
+
style: {
|
|
83
|
+
display: 'flex',
|
|
84
|
+
flex: 1,
|
|
85
|
+
flexDirection: 'column',
|
|
86
|
+
justifyContent: 'center'
|
|
87
|
+
},
|
|
88
|
+
children: [
|
|
89
|
+
{
|
|
90
|
+
type: 'div',
|
|
91
|
+
props: {
|
|
92
|
+
style: {
|
|
93
|
+
fontSize: title.length > 130 ? 48 : title.length > 80 ? 60 : 80,
|
|
94
|
+
lineHeight: 1.08,
|
|
95
|
+
letterSpacing: '-1.5px',
|
|
96
|
+
overflowWrap: 'anywhere'
|
|
97
|
+
},
|
|
98
|
+
children: title
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
type: 'div',
|
|
103
|
+
props: {
|
|
104
|
+
style: { marginTop: 24, fontSize: 26, color: '#7a7568' },
|
|
105
|
+
children: shorten(card.subtitle ?? '', 90)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: 'div',
|
|
113
|
+
props: {
|
|
114
|
+
style: {
|
|
115
|
+
display: 'flex',
|
|
116
|
+
justifyContent: 'space-between',
|
|
117
|
+
fontSize: 20,
|
|
118
|
+
color: '#7a7568'
|
|
119
|
+
},
|
|
120
|
+
children: [
|
|
121
|
+
{ type: 'span', props: { children: site.name } },
|
|
122
|
+
{ type: 'span', props: { children: new URL(site.url).host } }
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
...socialImageSize,
|
|
131
|
+
fonts: [{ name: 'Overused Grotesk', data: font, weight: 600, style: 'normal' }]
|
|
132
|
+
}
|
|
133
|
+
);
|
|
134
|
+
return new Resvg(svg, { font: { loadSystemFonts: false } }).render().asPng();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function courseLabel(slug: string) {
|
|
138
|
+
const course = slug.split('/')[0];
|
|
139
|
+
const match = /^(sp|su|fa|wi)(\d{2})-(.+)$/.exec(course);
|
|
140
|
+
if (!match) return course === 'des-inv' ? 'Design Innovation Lab' : 'Course notes';
|
|
141
|
+
const term = { sp: 'Spring', su: 'Summer', fa: 'Fall', wi: 'Winter' }[match[1]];
|
|
142
|
+
const code = match[3].replace(/([a-z])(\d)/, '$1 $2').toUpperCase();
|
|
143
|
+
return `${code} · ${term} 20${match[2]}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function socialCard(
|
|
147
|
+
page: Pick<ManifestPage, 'slug' | 'title' | 'heading' | 'relativePath'>
|
|
148
|
+
) {
|
|
149
|
+
return {
|
|
150
|
+
slug: page.slug,
|
|
151
|
+
title: page.heading?.trim() || page.title,
|
|
152
|
+
subtitle: path.posix.basename(page.relativePath),
|
|
153
|
+
label: courseLabel(page.slug)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const defaultSocialCard = {
|
|
158
|
+
slug: undefined,
|
|
159
|
+
title: site.title,
|
|
160
|
+
label: 'Notes'
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export async function cachedSocialImage(siteDir: string, card: SocialCard & { slug?: string }) {
|
|
164
|
+
const cache = path.join(siteDir, 'build/generated/cache/social-titles');
|
|
165
|
+
mkdirSync(cache, { recursive: true });
|
|
166
|
+
const key = createHash('sha256').update(rendererKey).update(JSON.stringify(card)).digest('hex');
|
|
167
|
+
const cached = path.join(cache, key + '.png');
|
|
168
|
+
const rendered = !existsSync(cached);
|
|
169
|
+
if (rendered) writeFileSync(cached, await renderSocialImage(card));
|
|
170
|
+
return { cached, rendered };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function buildSocialImages(siteDir: string, manifest: Pick<ContentManifest, 'pages'>) {
|
|
174
|
+
const output = path.join(staticDirectory(siteDir), '_og');
|
|
175
|
+
mkdirSync(output, { recursive: true });
|
|
176
|
+
const wanted = new Set<string>();
|
|
177
|
+
const current = new Set<string>();
|
|
178
|
+
let rendered = 0;
|
|
179
|
+
const cards = [defaultSocialCard, ...Object.values(manifest.pages).map(socialCard)];
|
|
180
|
+
for (const card of cards) {
|
|
181
|
+
const result = await cachedSocialImage(siteDir, card);
|
|
182
|
+
const cached = result.cached;
|
|
183
|
+
current.add(
|
|
184
|
+
path.relative(path.join(siteDir, 'build/generated/cache'), cached).split(path.sep).join('/')
|
|
185
|
+
);
|
|
186
|
+
if (result.rendered) rendered++;
|
|
187
|
+
const destination = path.join(
|
|
188
|
+
staticDirectory(siteDir),
|
|
189
|
+
decodeURIComponent(socialImagePath(card.slug))
|
|
190
|
+
);
|
|
191
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
192
|
+
wanted.add(destination);
|
|
193
|
+
if (!existsSync(destination) || !readFileSync(destination).equals(readFileSync(cached)))
|
|
194
|
+
copyFileSync(cached, destination);
|
|
195
|
+
}
|
|
196
|
+
for (const file of readdirSync(output, { recursive: true, withFileTypes: true })) {
|
|
197
|
+
const filename = path.join(file.parentPath, file.name);
|
|
198
|
+
if (file.isFile() && !wanted.has(filename)) rmSync(filename);
|
|
199
|
+
}
|
|
200
|
+
writeChanged(
|
|
201
|
+
path.join(
|
|
202
|
+
siteDir,
|
|
203
|
+
`build/generated/cache/social-titles-current-${process.env.VITE_PUBLIC_EDITION === 'true' ? 'public' : 'full'}.json`
|
|
204
|
+
),
|
|
205
|
+
JSON.stringify([...current].sort())
|
|
206
|
+
);
|
|
207
|
+
console.log(
|
|
208
|
+
`social images: ${cards.length} title-only cards (${rendered} rendered, ${cards.length - rendered} cached)`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { writeChanged } from './output';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { PageDoc } from '../../src/lib/types';
|
|
5
|
+
import { site } from '../../src/lib/config';
|
|
6
|
+
import { textExportUrl } from '../../src/lib/text-exports';
|
|
7
|
+
|
|
8
|
+
/** Called separately for each edition; locked outlines never become text exports. */
|
|
9
|
+
export function writeTextExports(directory: string, pages: PageDoc[], assets = new Set<string>()) {
|
|
10
|
+
const written = new Set<string>();
|
|
11
|
+
const index = [
|
|
12
|
+
`# ${site.title}`,
|
|
13
|
+
'',
|
|
14
|
+
`> ${site.description}`,
|
|
15
|
+
'',
|
|
16
|
+
'Notes are available as UTF-8 Markdown (.md) and plain-text Markdown (.txt).',
|
|
17
|
+
'Obsidian wikilinks and LaTeX math are preserved. Restricted notes require site access.',
|
|
18
|
+
'',
|
|
19
|
+
'## Notes',
|
|
20
|
+
''
|
|
21
|
+
];
|
|
22
|
+
for (const page of [...pages].sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
23
|
+
if (page.locked || page.markdown === undefined) continue;
|
|
24
|
+
const route = page.slug === 'index' ? '' : page.slug.replace(/\/index$/, '');
|
|
25
|
+
const source = `${site.url}/${route.split('/').map(encodeURIComponent).join('/')}`;
|
|
26
|
+
const text = `# ${page.title}\n\nSource: ${source}\n\n${page.markdown.trim()}\n`;
|
|
27
|
+
for (const format of ['md', 'txt'] as const) {
|
|
28
|
+
const file = path.join(
|
|
29
|
+
directory,
|
|
30
|
+
decodeURIComponent(textExportUrl(page.slug, format)).slice(1)
|
|
31
|
+
);
|
|
32
|
+
if (
|
|
33
|
+
assets.has(path.relative(directory, file)) ||
|
|
34
|
+
written.has(path.relative(directory, file)) ||
|
|
35
|
+
path.relative(directory, file) === 'llms.txt'
|
|
36
|
+
)
|
|
37
|
+
throw new Error(`Text export conflicts with an existing asset: ${page.slug}.${format}`);
|
|
38
|
+
if (Buffer.byteLength(text) > 25 * 1024 * 1024)
|
|
39
|
+
throw new Error(`Text export exceeds deployment size limit: ${page.slug}`);
|
|
40
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
41
|
+
writeChanged(file, text);
|
|
42
|
+
written.add(path.relative(directory, file));
|
|
43
|
+
}
|
|
44
|
+
const title = page.title.replace(/[\r\n]/g, ' ').replace(/[\\[\]]/g, '\\$&');
|
|
45
|
+
index.push(`- [${title}](${site.url}${textExportUrl(page.slug)})`);
|
|
46
|
+
}
|
|
47
|
+
if (assets.has('llms.txt')) throw new Error('Text export conflicts with llms.txt');
|
|
48
|
+
writeChanged(path.join(directory, 'llms.txt'), index.join('\n') + '\n');
|
|
49
|
+
written.add('llms.txt');
|
|
50
|
+
return written;
|
|
51
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Element, Root } from 'hast';
|
|
2
|
+
import { SKIP } from 'unist-util-visit';
|
|
3
|
+
|
|
4
|
+
/** Preorder HAST traversal for visitors that only mutate the current element.
|
|
5
|
+
* Child mutations are visited immediately; SKIP prunes the current subtree.
|
|
6
|
+
* Sibling replacement and index/ancestor callbacks still need unist-util-visit.
|
|
7
|
+
*/
|
|
8
|
+
export function visitElements(
|
|
9
|
+
tree: Root | Element,
|
|
10
|
+
visitor: (node: Element) => void | typeof SKIP
|
|
11
|
+
) {
|
|
12
|
+
if (tree.type === 'element' && visitor(tree) === SKIP) return;
|
|
13
|
+
for (let index = 0; index < tree.children.length; index++) {
|
|
14
|
+
const child = tree.children[index];
|
|
15
|
+
if (child.type === 'element') visitElements(child, visitor);
|
|
16
|
+
}
|
|
17
|
+
}
|