mirror of
https://github.com/lucifer1004/claude-skill-typst.git
synced 2026-09-19 07:28:49 +08:00
423 lines
12 KiB
Rust
423 lines
12 KiB
Rust
//! Export Typst's current standard-library API as normalized JSON.
|
|
//!
|
|
//! This file is copied into an upstream `typst/typst` checkout as
|
|
//! `docs/src/bin/export-api.rs` and compiled as part of the `typst-docs`
|
|
//! package. Keeping it in this repository lets CI use upstream `main` without
|
|
//! maintaining a fork.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::env;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::Serialize;
|
|
use typst::foundations::{
|
|
CastInfo, Func, NativeParamInfo, Repr, Scope, SilentBindingGuard, Symbol, Type, Value,
|
|
};
|
|
use typst::{Features, Library, LibraryExt};
|
|
use typst_library::Category;
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ApiEntry {
|
|
name: String,
|
|
category: String,
|
|
kind: String,
|
|
oneliner: String,
|
|
params: Vec<ApiParam>,
|
|
returns: Vec<String>,
|
|
route: String,
|
|
weight: f64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
value: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
contextual: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
element: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
deprecated: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ApiParam {
|
|
name: String,
|
|
types: Vec<String>,
|
|
required: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
default: Option<String>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
strings: Vec<String>,
|
|
}
|
|
|
|
fn main() {
|
|
let out = parse_out_arg();
|
|
let entries = export_entries();
|
|
write_entries(&out, &entries);
|
|
}
|
|
|
|
fn parse_out_arg() -> PathBuf {
|
|
let mut args = env::args().skip(1);
|
|
while let Some(arg) = args.next() {
|
|
if arg == "--out" {
|
|
return args
|
|
.next()
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| usage("--out requires a path"));
|
|
}
|
|
if let Some(path) = arg.strip_prefix("--out=") {
|
|
return PathBuf::from(path);
|
|
}
|
|
usage(&format!("unexpected argument: {arg}"));
|
|
}
|
|
usage("missing --out");
|
|
}
|
|
|
|
fn usage(message: &str) -> ! {
|
|
eprintln!("{message}");
|
|
eprintln!("usage: export-api --out <path>");
|
|
std::process::exit(2);
|
|
}
|
|
|
|
fn write_entries(out: &PathBuf, entries: &[ApiEntry]) {
|
|
if let Some(parent) = out.parent() {
|
|
fs::create_dir_all(parent).expect("create output directory");
|
|
}
|
|
let data = serde_json::to_vec(entries).expect("serialize API entries");
|
|
fs::write(out, data).expect("write API entries");
|
|
eprintln!("wrote {} entries to {}", entries.len(), out.display());
|
|
}
|
|
|
|
fn export_entries() -> Vec<ApiEntry> {
|
|
let library = Library::builder([
|
|
typst_html::FORMAT,
|
|
typst_pdf::FORMAT,
|
|
typst_svg::FORMAT,
|
|
typst_render::FORMAT,
|
|
typst_bundle::FORMAT,
|
|
])
|
|
.with_features(Features::all())
|
|
.build();
|
|
let guard = SilentBindingGuard::new(Features::all());
|
|
let mut entries = Vec::new();
|
|
collect_scope(library.global.scope(), None, "", &guard, &mut entries);
|
|
entries
|
|
}
|
|
|
|
fn collect_scope(
|
|
scope: &Scope,
|
|
inherited_category: Option<Category>,
|
|
prefix: &str,
|
|
guard: &SilentBindingGuard,
|
|
entries: &mut Vec<ApiEntry>,
|
|
) {
|
|
for (name, binding) in scope.iter() {
|
|
let Ok(value) = binding.read(guard) else { continue };
|
|
let category = binding.category().or(inherited_category);
|
|
let path = join_path(prefix, name.as_str());
|
|
|
|
match value {
|
|
Value::Func(func) => {
|
|
if let Some(category) = category {
|
|
push_func(entries, &path, category, "function", func, None, guard);
|
|
}
|
|
}
|
|
Value::Type(ty) => {
|
|
if let Some(category) = category {
|
|
push_type(entries, &path, category, ty, guard);
|
|
}
|
|
}
|
|
Value::Symbol(symbol) => {
|
|
if path.starts_with("sym.") || path.starts_with("emoji.") {
|
|
push_symbol(entries, &path, symbol);
|
|
}
|
|
}
|
|
Value::Module(module) => {
|
|
collect_scope(module.scope(), category, &path, guard, entries);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_type(
|
|
entries: &mut Vec<ApiEntry>,
|
|
path: &str,
|
|
category: Category,
|
|
ty: &Type,
|
|
guard: &SilentBindingGuard,
|
|
) {
|
|
let name = ty.short_name().to_string();
|
|
let route = route_for_top_level(category, path);
|
|
entries.push(ApiEntry {
|
|
name: name.clone(),
|
|
category: category_title(category).to_string(),
|
|
kind: "type".to_string(),
|
|
oneliner: oneliner(ty.docs()),
|
|
params: vec![],
|
|
returns: vec![],
|
|
route: route.clone(),
|
|
weight: category_weight(category),
|
|
value: None,
|
|
contextual: None,
|
|
element: None,
|
|
deprecated: None,
|
|
});
|
|
|
|
if let Ok(constructor) = ty.constructor() {
|
|
push_func(entries, &name, category, "constructor", &constructor, Some(&route), guard);
|
|
}
|
|
|
|
push_scoped_defs(entries, &name, category, ty.scope(), &route, guard);
|
|
}
|
|
|
|
fn push_func(
|
|
entries: &mut Vec<ApiEntry>,
|
|
path: &str,
|
|
category: Category,
|
|
kind: &str,
|
|
func: &Func,
|
|
route_override: Option<&str>,
|
|
guard: &SilentBindingGuard,
|
|
) {
|
|
let route = route_override
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| route_for_func(category, path));
|
|
let entry_name = if kind == "constructor" {
|
|
path.to_string()
|
|
} else {
|
|
path.to_string()
|
|
};
|
|
entries.push(ApiEntry {
|
|
name: entry_name.clone(),
|
|
category: category_title(category).to_string(),
|
|
kind: kind.to_string(),
|
|
oneliner: func.docs().map(oneliner).unwrap_or_default(),
|
|
params: func
|
|
.params()
|
|
.filter_map(|param| param.to_native().map(param_info))
|
|
.collect(),
|
|
returns: func.returns().map(cast_types).unwrap_or_default(),
|
|
route: route.clone(),
|
|
weight: category_weight(category),
|
|
value: None,
|
|
contextual: func.contextual().filter(|value| *value),
|
|
element: func.to_element().map(|_| true),
|
|
deprecated: None,
|
|
});
|
|
|
|
if kind != "method" {
|
|
if let Some(scope) = func.scope() {
|
|
push_scoped_defs(entries, &entry_name, category, scope, &route, guard);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_scoped_defs(
|
|
entries: &mut Vec<ApiEntry>,
|
|
parent: &str,
|
|
category: Category,
|
|
scope: &Scope,
|
|
route: &str,
|
|
guard: &SilentBindingGuard,
|
|
) {
|
|
for (name, binding) in scope.iter() {
|
|
if let Ok(Value::Func(func)) = binding.read(guard) {
|
|
let full_name = format!("{parent}.{name}");
|
|
push_func(entries, &full_name, category, "method", func, Some(route), guard);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn push_symbol(entries: &mut Vec<ApiEntry>, path: &str, symbol: &Symbol) {
|
|
let mut seen = BTreeSet::new();
|
|
for (modifiers, value, _) in symbol.variants() {
|
|
let symbol_base = path
|
|
.strip_prefix("sym.")
|
|
.or_else(|| path.strip_prefix("emoji."))
|
|
.unwrap_or(path);
|
|
let suffix = modifiers
|
|
.into_iter()
|
|
.filter(|modifier| !modifier.is_empty())
|
|
.collect::<Vec<_>>()
|
|
.join(".");
|
|
let name = if suffix.is_empty() {
|
|
symbol_base.to_string()
|
|
} else {
|
|
format!("{symbol_base}.{suffix}")
|
|
};
|
|
if !seen.insert(name.clone()) {
|
|
continue;
|
|
}
|
|
entries.push(ApiEntry {
|
|
name: format!("sym.{name}"),
|
|
category: "Symbols".to_string(),
|
|
kind: "symbol".to_string(),
|
|
oneliner: format!("{value} (sym.{name})"),
|
|
params: vec![],
|
|
returns: vec![],
|
|
route: symbol_route(path),
|
|
weight: category_weight(Category::Symbols),
|
|
value: Some(value.to_string()),
|
|
contextual: None,
|
|
element: None,
|
|
deprecated: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn param_info(param: &NativeParamInfo) -> ApiParam {
|
|
ApiParam {
|
|
name: param.name.to_string(),
|
|
types: cast_types(¶m.input),
|
|
required: param.required,
|
|
default: param.default.map(|make| make().repr().to_string()),
|
|
strings: cast_strings(¶m.input),
|
|
}
|
|
}
|
|
|
|
fn cast_types(info: &CastInfo) -> Vec<String> {
|
|
let mut values = Vec::new();
|
|
collect_cast_types(info, &mut values);
|
|
dedup(values)
|
|
}
|
|
|
|
fn collect_cast_types(info: &CastInfo, values: &mut Vec<String>) {
|
|
match info {
|
|
CastInfo::Any => values.push("any".to_string()),
|
|
CastInfo::Value(value, _) => values.push(value.ty().short_name().to_string()),
|
|
CastInfo::Type(ty) => values.push(ty.short_name().to_string()),
|
|
CastInfo::Union(infos) => {
|
|
for info in infos {
|
|
collect_cast_types(info, values);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cast_strings(info: &CastInfo) -> Vec<String> {
|
|
let mut values = Vec::new();
|
|
collect_cast_strings(info, &mut values);
|
|
dedup(values)
|
|
}
|
|
|
|
fn collect_cast_strings(info: &CastInfo, values: &mut Vec<String>) {
|
|
match info {
|
|
CastInfo::Value(Value::Str(value), _) => values.push(value.to_string()),
|
|
CastInfo::Union(infos) => {
|
|
for info in infos {
|
|
collect_cast_strings(info, values);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn dedup(values: Vec<String>) -> Vec<String> {
|
|
let mut seen = BTreeSet::new();
|
|
values
|
|
.into_iter()
|
|
.filter(|value| seen.insert(value.clone()))
|
|
.collect()
|
|
}
|
|
|
|
fn oneliner(docs: &str) -> String {
|
|
let first = docs
|
|
.split("\n\n")
|
|
.next()
|
|
.unwrap_or("")
|
|
.lines()
|
|
.map(str::trim)
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
strip_markdown(&first)
|
|
}
|
|
|
|
fn strip_markdown(text: &str) -> String {
|
|
text.replace('`', "")
|
|
.replace('[', "")
|
|
.replace(']', "")
|
|
.replace('*', "")
|
|
.replace('_', "")
|
|
.trim()
|
|
.to_string()
|
|
}
|
|
|
|
fn join_path(prefix: &str, name: &str) -> String {
|
|
if prefix.is_empty() {
|
|
name.to_string()
|
|
} else {
|
|
format!("{prefix}.{name}")
|
|
}
|
|
}
|
|
|
|
fn route_for_func(category: Category, path: &str) -> String {
|
|
let parts = path.split('.').collect::<Vec<_>>();
|
|
let page = if parts.len() == 1 {
|
|
parts[0]
|
|
} else if category_slug(category) == parts[0] && parts.len() > 1 {
|
|
parts[1]
|
|
} else {
|
|
parts[0]
|
|
};
|
|
format!("/reference/{}/{}/", category_slug(category), page)
|
|
}
|
|
|
|
fn route_for_top_level(category: Category, path: &str) -> String {
|
|
let first = path.split('.').next().unwrap_or(path);
|
|
let page = if category_slug(category) == first {
|
|
path.split('.').nth(1).unwrap_or(first)
|
|
} else {
|
|
first
|
|
};
|
|
format!("/reference/{}/{}/", category_slug(category), page)
|
|
}
|
|
|
|
fn symbol_route(path: &str) -> String {
|
|
if path.starts_with("emoji.") {
|
|
"/reference/symbols/emoji/".to_string()
|
|
} else {
|
|
"/reference/symbols/sym/".to_string()
|
|
}
|
|
}
|
|
|
|
fn category_slug(category: Category) -> &'static str {
|
|
category.name()
|
|
}
|
|
|
|
fn category_title(category: Category) -> &'static str {
|
|
match category {
|
|
Category::Foundations => "Foundations",
|
|
Category::Introspection => "Introspection",
|
|
Category::Layout => "Layout",
|
|
Category::DataLoading => "Data Loading",
|
|
Category::Math => "Math",
|
|
Category::Model => "Model",
|
|
Category::Symbols => "Symbols",
|
|
Category::Text => "Text",
|
|
Category::Visualize => "Visualize",
|
|
Category::Format => "Format",
|
|
Category::Pdf => "PDF",
|
|
Category::Html => "HTML",
|
|
Category::Svg => "SVG",
|
|
Category::Png => "PNG",
|
|
Category::Bundle => "Bundle",
|
|
}
|
|
}
|
|
|
|
fn category_weight(category: Category) -> f64 {
|
|
match category {
|
|
Category::Foundations
|
|
| Category::Introspection
|
|
| Category::Layout
|
|
| Category::DataLoading
|
|
| Category::Math
|
|
| Category::Model
|
|
| Category::Text
|
|
| Category::Visualize
|
|
| Category::Format => 1.5,
|
|
Category::Symbols => 1.0,
|
|
Category::Pdf => 0.5,
|
|
Category::Html => 0.3,
|
|
Category::Svg | Category::Png | Category::Bundle => 1.0,
|
|
}
|
|
}
|