1use crate::assets::AssetStrategy;
2use crate::content::Content;
3use crate::metadata::MetadataService;
4use crate::resolved_config::ResolvedConfig;
5use anyhow::Result;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use typub_config::{Config, PlatformConfig, StorageConfig};
10use typub_storage::StatusTracker;
11
12use typub_adapters_core::{ContentInfo, resolve_asset_strategy_with_policy};
13
14pub fn content_info_from(content: &Content) -> ContentInfo {
15 ContentInfo::new(
16 content.meta.title.clone(),
17 content.slug().to_string(),
18 content.path.clone(),
19 content.meta.tags.clone(),
20 content.meta.categories.clone(),
21 content.assets.clone(),
22 )
23}
24
25pub fn content_info_with_platform(
26 content: &Content,
27 platform_id: &str,
28 config: &Config,
29) -> ContentInfo {
30 let mut all_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
32
33 if let Some(post_cfg) = content.platform_config(platform_id) {
35 all_keys.extend(post_cfg.extra.keys().cloned());
36 }
37
38 if let Some(global_cfg) = config.platforms.get(platform_id) {
40 all_keys.extend(global_cfg.extra.keys().cloned());
41 }
42
43 let platform_extra: std::collections::HashMap<String, String> = all_keys
45 .into_iter()
46 .filter_map(|key| {
47 crate::resolved_config::resolve_platform_field(content, platform_id, config, &key, None)
48 .map(|value| (key, value))
49 })
50 .collect();
51
52 ContentInfo::with_platform_extra(
53 content.meta.title.clone(),
54 content.slug().to_string(),
55 content.path.clone(),
56 content.meta.tags.clone(),
57 content.meta.categories.clone(),
58 content.assets.clone(),
59 platform_extra,
60 )
61}
62
63use typub_core::{
64 CapabilityGapBehavior, CapabilitySupport, DraftSupport, MathDelimiters, MathRendering,
65 NodePolicyAction,
66};
67
68use typub_adapters_core::{AdapterCapability, ImageStrategyPolicy, NodePolicy, TaxonomyCapability};
69
70use CapabilityGapBehavior as UnsupportedBehavior;
71
72pub static BUILTIN_ADAPTERS: &[AdapterCapability] = &[
73 typub_adapter_ghost::CAPABILITY,
74 typub_adapter_devto::CAPABILITY,
75 typub_adapter_wordpress::CAPABILITY,
76 typub_adapter_hashnode::CAPABILITY,
77 typub_adapter_confluence::CAPABILITY,
78 typub_adapter_astro::CAPABILITY,
79 typub_adapter_static::CAPABILITY,
80 typub_adapter_xiaohongshu::CAPABILITY,
81 typub_adapter_notion::CAPABILITY,
82];
83
84const COPYPASTE_DEFAULT_CAPABILITY: AdapterCapability = AdapterCapability {
85 id: "copypaste",
86 name: "Copy-Paste",
87 short_code: "cp",
88 local_output: true,
89 requires_config: false,
90 taxonomy: TaxonomyCapability::new(
91 CapabilitySupport::Unsupported(UnsupportedBehavior::WarnAndDegrade),
92 CapabilitySupport::Unsupported(UnsupportedBehavior::WarnAndDegrade),
93 CapabilitySupport::Supported,
94 DraftSupport::None,
95 ),
96 asset_strategies: &[AssetStrategy::Embed, AssetStrategy::External],
97 math_renderings: &[MathRendering::Svg, MathRendering::Png],
98 math_delimiters: &[MathDelimiters::Dollar, MathDelimiters::Brackets],
99 code_highlight: false,
100 notes: "Copy-paste format has no metadata sync. Internal links resolve to other published platforms.",
101 node_policy: NodePolicy {
102 raw: NodePolicyAction::Sanitize,
103 unknown: NodePolicyAction::Drop,
104 },
105};
106
107pub fn all_adapter_capabilities() -> &'static [AdapterCapability] {
108 BUILTIN_ADAPTERS
109}
110
111pub fn adapter_capability(id: &str) -> Option<&'static AdapterCapability> {
112 BUILTIN_ADAPTERS.iter().find(|c| c.id == id).or_else(|| {
113 if typub_adapter_copypaste::find_profile(id).is_some() {
115 Some(©PASTE_DEFAULT_CAPABILITY)
116 } else {
117 None
118 }
119 })
120}
121
122pub fn resolve_math_rendering(platform_id: &str) -> MathRendering {
123 adapter_capability(platform_id)
124 .map(|cap| cap.default_math_rendering())
125 .unwrap_or(MathRendering::Svg)
126}
127
128pub fn resolve_math_delimiters(platform_id: &str) -> MathDelimiters {
129 adapter_capability(platform_id)
130 .map(|cap| cap.default_math_delimiter())
131 .unwrap_or(MathDelimiters::Dollar)
132}
133
134pub fn resolve_code_highlight(platform_id: &str) -> bool {
135 adapter_capability(platform_id)
136 .map(|cap| cap.code_highlight)
137 .unwrap_or(false)
138}
139
140pub fn is_local_output_platform(id: &str) -> bool {
141 if let Some(cap) = BUILTIN_ADAPTERS.iter().find(|c| c.id == id) {
143 return cap.local_output;
144 }
145
146 typub_adapter_copypaste::find_profile(id).is_some()
148}
149
150pub fn is_copypaste_platform(id: &str) -> bool {
151 typub_adapter_copypaste::find_profile(id).is_some()
152}
153
154pub fn platform_short_code(id: &str) -> Option<&'static str> {
155 if let Some(cap) = BUILTIN_ADAPTERS.iter().find(|c| c.id == id) {
157 return Some(cap.short_code);
158 }
159 if let Some(profile) = typub_adapter_copypaste::find_profile(id) {
161 return Some(profile.short_code);
162 }
163 None
164}
165
166pub fn resolve_platform_asset_strategy(
167 platform_id: &str,
168 platform_config: Option<&PlatformConfig>,
169 default: AssetStrategy,
170) -> Result<AssetStrategy> {
171 ResolvedConfig::resolve_asset_strategy_from_platform_config(
172 platform_id,
173 platform_config,
174 default,
175 )
176}
177
178pub fn resolve_asset_strategy_from_capability(
179 platform_id: &str,
180 platform_config: Option<&PlatformConfig>,
181) -> Result<AssetStrategy> {
182 let cap = adapter_capability(platform_id)
183 .ok_or_else(|| anyhow::anyhow!("Unknown platform: {}", platform_id))?;
184
185 resolve_platform_asset_strategy_with_policy(
186 platform_id,
187 platform_config,
188 cap.default_asset_strategy(),
189 cap.asset_strategy_policy(),
190 )
191}
192
193pub fn resolve_platform_asset_strategy_with_policy(
194 platform_id: &str,
195 platform_config: Option<&PlatformConfig>,
196 default: AssetStrategy,
197 policy: ImageStrategyPolicy,
198) -> Result<AssetStrategy> {
199 resolve_asset_strategy_with_policy(platform_id, platform_config, default, policy.supported)
201}
202
203pub struct PublishContext {
204 pub status: StatusTracker,
205 pub metadata: Arc<dyn MetadataService>,
206 resolved: Option<crate::resolved_config::ResolvedConfig>,
207 content_info: ContentInfo,
208 pub dry_run: bool,
210}
211
212impl PublishContext {
213 pub fn new(_config: &Config) -> Result<Self> {
214 let status = StatusTracker::load(std::path::Path::new("."))?;
215 let metadata = Arc::new(crate::metadata::DefaultMetadataService {});
216 Ok(Self {
217 status,
218 metadata,
219 resolved: None,
220 content_info: ContentInfo::minimal("", "", std::path::PathBuf::new()),
221 dry_run: false,
222 })
223 }
224
225 pub fn new_with_root(_config: &Config, project_root: &Path) -> Result<Self> {
226 let status = StatusTracker::load(project_root)?;
227 let metadata = Arc::new(crate::metadata::DefaultMetadataService {});
228 Ok(Self {
229 status,
230 metadata,
231 resolved: None,
232 content_info: ContentInfo::minimal("", "", std::path::PathBuf::new()),
233 dry_run: false,
234 })
235 }
236
237 pub fn new_dry_run(_config: &Config, project_root: &Path) -> Result<Self> {
240 let status = StatusTracker::load(project_root)?;
241 let metadata = Arc::new(crate::metadata::DefaultMetadataService {});
242 Ok(Self {
243 status,
244 metadata,
245 resolved: None,
246 content_info: ContentInfo::minimal("", "", std::path::PathBuf::new()),
247 dry_run: true,
248 })
249 }
250
251 pub fn set_resolved(&mut self, resolved: crate::resolved_config::ResolvedConfig) {
252 self.resolved = Some(resolved);
253 }
254
255 pub fn resolved(&self) -> Option<&crate::resolved_config::ResolvedConfig> {
256 self.resolved.as_ref()
257 }
258
259 pub fn set_content_info(&mut self, content_info: ContentInfo) {
260 self.content_info = content_info;
261 }
262
263 pub fn content_info(&self) -> &ContentInfo {
264 &self.content_info
265 }
266}
267
268impl typub_adapters_core::AdapterContext for PublishContext {
269 fn get_platform_id(&self, slug: &str, platform: &str) -> Result<Option<String>> {
270 self.status.get_platform_id(slug, platform)
271 }
272
273 fn normalize_terms(&self, terms: &[String]) -> Vec<String> {
274 self.metadata.normalize_terms(terms)
275 }
276
277 fn published(&self) -> bool {
278 self.resolved.as_ref().map(|r| r.published).unwrap_or(false)
279 }
280
281 fn storage_config(&self) -> Option<&StorageConfig> {
282 self.resolved.as_ref().and_then(|r| r.storage.as_ref())
283 }
284
285 fn theme_id(&self) -> Option<&str> {
286 self.resolved.as_ref().and_then(|r| r.theme_id.as_deref())
287 }
288
289 fn content_info(&self) -> &ContentInfo {
290 &self.content_info
291 }
292
293 fn status_tracker(&self) -> Option<&StatusTracker> {
294 Some(&self.status)
295 }
296
297 fn is_dry_run(&self) -> bool {
298 self.dry_run
299 }
300}
301
302pub use crate::assets::ensure_no_unresolved_image_markers;
303
304pub fn write_preview_file(slug: &str, platform: &str, html: &str) -> Result<PathBuf> {
305 let dir = std::env::temp_dir().join("typub-preview");
306 std::fs::create_dir_all(&dir)?;
307 let path = dir.join(format!("{slug}-{platform}.html"));
308 std::fs::write(&path, html)?;
309 Ok(path)
310}
311
312use typub_adapters_core::PlatformAdapter;
313
314pub struct AdapterRegistry {
315 adapters: HashMap<String, Box<dyn PlatformAdapter>>,
316}
317
318impl AdapterRegistry {
319 pub fn new(config: &Config) -> Result<Self> {
320 type Factory = fn(&Config) -> Result<Box<dyn PlatformAdapter>>;
321
322 let factories: Vec<(&str, Factory, bool)> = vec![
324 ("astro", typub_adapter_astro::create, false),
325 ("static", typub_adapter_static::create, false),
326 ("xiaohongshu", typub_adapter_xiaohongshu::create, false),
327 ("confluence", typub_adapter_confluence::create, true),
328 ("devto", typub_adapter_devto::create, true),
329 ("ghost", typub_adapter_ghost::create, true),
330 ("hashnode", typub_adapter_hashnode::create, true),
331 ("notion", typub_adapter_notion::create, true),
332 ("wordpress", typub_adapter_wordpress::create, true),
333 ];
334
335 let mut adapters: HashMap<String, Box<dyn PlatformAdapter>> = HashMap::new();
336
337 for (id, factory, requires_config) in &factories {
338 let platform_config = config.get_platform(id);
339 let should_register = if *requires_config {
340 platform_config.is_some_and(|p| p.enabled)
342 } else {
343 platform_config.is_none_or(|p| p.enabled)
345 };
346
347 if should_register {
348 adapters.insert(id.to_string(), factory(config)?);
349 }
350 }
351
352 for profile in typub_adapter_copypaste::all_profiles() {
353 let explicitly_disabled = config.get_platform(profile.id).is_some_and(|p| !p.enabled);
354 if !explicitly_disabled {
355 let adapter =
356 typub_adapter_copypaste::CopyPasteAdapter::from_profile(profile, config)?;
357 adapters.insert(profile.id.to_string(), Box::new(adapter));
358 }
359 }
360
361 for (id, pcfg) in &config.platforms {
362 if !pcfg.enabled || adapters.contains_key(id) {
363 continue;
364 }
365 if pcfg.get_str("type").as_deref() == Some("manual") {
366 let id_static: &'static str = Box::leak(id.clone().into_boxed_str());
367 let adapter = typub_adapter_copypaste::CopyPasteAdapter::from_manual_config(
368 id_static, pcfg, config,
369 )?;
370 adapters.insert(id.clone(), Box::new(adapter));
371 }
372 }
373
374 Ok(Self { adapters })
375 }
376
377 pub fn get(&self, id: &str) -> Result<&dyn PlatformAdapter> {
378 self.adapters.get(id).map(|a| a.as_ref()).ok_or_else(|| {
379 anyhow::anyhow!(
380 "Platform '{}' not found. It may be disabled (enabled = false) or not a known platform ID.",
381 id,
382 )
383 })
384 }
385
386 pub fn list(&self) -> Vec<&str> {
387 self.adapters.keys().map(|s| s.as_str()).collect()
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 #![allow(clippy::expect_used)]
394
395 use super::*;
396 use typub_adapters_core::{AdapterPayload, downcast_payload};
397 use typub_html::{document, document_with_assets, image_marker};
398
399 #[test]
400 fn test_resolve_platform_asset_strategy_uses_default_when_missing() {
401 let strategy = resolve_platform_asset_strategy("devto", None, AssetStrategy::Upload)
402 .expect("should resolve default strategy");
403 assert_eq!(strategy, AssetStrategy::Upload);
404 }
405
406 #[test]
407 fn test_resolve_platform_asset_strategy_parses_override() {
408 let cfg = PlatformConfig {
409 enabled: true,
410 asset_strategy: Some("embed".to_string()),
411 published: None,
412 theme: None,
413 internal_link_target: None,
414 math_rendering: None,
415 math_delimiters: None,
416 extra: HashMap::new(),
417 };
418 let strategy = resolve_platform_asset_strategy("wechat", Some(&cfg), AssetStrategy::Copy)
419 .expect("should parse strategy override");
420 assert_eq!(strategy, AssetStrategy::Embed);
421 }
422
423 #[test]
424 fn test_resolve_platform_asset_strategy_rejects_invalid_value() {
425 let cfg = PlatformConfig {
426 enabled: true,
427 asset_strategy: Some("invalid".to_string()),
428 published: None,
429 theme: None,
430 internal_link_target: None,
431 math_rendering: None,
432 math_delimiters: None,
433 extra: HashMap::new(),
434 };
435 let err = resolve_platform_asset_strategy("astro", Some(&cfg), AssetStrategy::Copy)
436 .expect_err("invalid strategy should error");
437 assert!(err.to_string().contains("Invalid asset strategy"));
438 }
439
440 #[test]
441 fn test_policy_rejects_unsupported_strategy() {
442 let cfg = PlatformConfig {
443 enabled: true,
444 asset_strategy: Some("upload".to_string()),
445 published: None,
446 theme: None,
447 internal_link_target: None,
448 math_rendering: None,
449 math_delimiters: None,
450 extra: HashMap::new(),
451 };
452 let err = resolve_platform_asset_strategy_with_policy(
453 "astro",
454 Some(&cfg),
455 AssetStrategy::Copy,
456 ImageStrategyPolicy {
457 supported: &[AssetStrategy::Copy, AssetStrategy::Embed],
458 },
459 )
460 .expect_err("unsupported strategy should error");
461 assert!(err.to_string().contains("is not supported"));
462 }
463
464 #[test]
467 fn test_all_adapter_capabilities_count() {
468 let caps = all_adapter_capabilities();
469 assert_eq!(caps.len(), 9);
470 }
471
472 #[test]
473 fn test_adapter_capability_api_platforms() {
474 for id in &[
475 "astro",
476 "static",
477 "confluence",
478 "devto",
479 "ghost",
480 "hashnode",
481 "notion",
482 "wordpress",
483 "xiaohongshu",
484 ] {
485 let cap = adapter_capability(id);
486 assert!(cap.is_some(), "capability for '{id}' should exist");
487 assert_eq!(cap.expect("checked").id, *id);
488 }
489 }
490
491 #[test]
492 fn test_adapter_capability_has_node_policy() {
493 for id in &[
494 "astro",
495 "static",
496 "confluence",
497 "devto",
498 "ghost",
499 "hashnode",
500 "notion",
501 "wordpress",
502 "xiaohongshu",
503 "wechat",
504 ] {
505 let cap = adapter_capability(id).expect("capability should exist");
506 let _ = cap.node_policy();
507 }
508 }
509
510 #[test]
511 fn test_adapter_capability_copypaste_fallback() {
512 let cap = adapter_capability("wechat");
514 assert!(cap.is_some());
515 assert_eq!(cap.expect("checked").id, "copypaste");
516 }
517
518 #[test]
519 fn test_adapter_capability_unknown_returns_none() {
520 assert!(adapter_capability("nonexistent").is_none());
521 }
522
523 #[test]
524 fn test_capability_support_gap_behavior() {
525 assert!(CapabilitySupport::Supported.gap_behavior().is_none());
526 assert_eq!(
527 CapabilitySupport::Unsupported(CapabilityGapBehavior::WarnAndDegrade).gap_behavior(),
528 Some(CapabilityGapBehavior::WarnAndDegrade)
529 );
530 assert_eq!(
531 CapabilitySupport::Unsupported(CapabilityGapBehavior::HardError).gap_behavior(),
532 Some(CapabilityGapBehavior::HardError)
533 );
534 }
535
536 #[test]
537 fn test_adapter_capability_gap_methods() {
538 let cap = adapter_capability("astro").expect("astro exists");
539 assert!(cap.tags_gap_behavior().is_none());
541 assert!(cap.categories_gap_behavior().is_none());
542 assert!(cap.internal_links_gap_behavior().is_none()); let cap = adapter_capability("wordpress").expect("wordpress exists");
545 assert!(cap.tags_gap_behavior().is_none());
547 assert!(cap.categories_gap_behavior().is_none());
548 assert!(cap.internal_links_gap_behavior().is_none());
549 }
550
551 #[test]
552 fn test_image_strategy_policy_allow_all() {
553 let policy = ImageStrategyPolicy::allow_all();
554 assert_eq!(policy.supported.len(), 4);
555 assert!(policy.supported.contains(&AssetStrategy::Copy));
556 assert!(policy.supported.contains(&AssetStrategy::Embed));
557 assert!(policy.supported.contains(&AssetStrategy::Upload));
558 assert!(policy.supported.contains(&AssetStrategy::External));
559 }
560
561 #[test]
562 fn test_downcast_payload_success() {
563 let payload = AdapterPayload::simple(42u32, "test-slug");
564 let value: u32 = downcast_payload(payload, "test").expect("downcast should succeed");
565 assert_eq!(value, 42);
566 }
567
568 #[test]
569 fn test_downcast_payload_wrong_type() {
570 let payload = AdapterPayload::simple(42u32, "test-slug");
571 let err = downcast_payload::<String>(payload, "test").expect_err("wrong type");
572 assert!(err.to_string().contains("Invalid test publish payload"));
573 }
574
575 #[test]
576 fn test_adapter_payload_with_assets() {
577 use crate::assets::{AssetStrategy, DeferredAssets, PendingAsset, PendingAssetList};
578 use std::path::PathBuf;
579
580 let pending = PendingAssetList {
581 assets: vec![PendingAsset {
582 index: 0,
583 local_path: PathBuf::from("/tmp/a.png"),
584 original_ref: "a.png".to_string(),
585 }],
586 };
587 let assets = DeferredAssets::new(pending, AssetStrategy::External);
588 let content_info = ContentInfo::minimal("Test", "test-slug", "/tmp");
589 let payload = AdapterPayload::new(42u32, content_info, assets, document(Vec::new()));
590
591 assert!(payload.assets.needs_materialize());
592 let value: u32 = payload.downcast("test").expect("downcast");
593 assert_eq!(value, 42);
594 }
595
596 #[test]
597 fn test_adapter_payload_map_inner() {
598 let payload = AdapterPayload::simple(42u32, "test-slug");
599 let mapped = payload
600 .map_inner::<u32, _>("test", |v| v * 2)
601 .expect("map inner");
602 let value: u32 = mapped.downcast("test").expect("downcast");
603 assert_eq!(value, 84);
604 }
605
606 #[test]
607 fn test_ensure_no_unresolved_image_markers_no_deferred_strategy() {
608 let (block, (asset_id, asset)) =
609 image_marker("asset-a", "assets/a.png", "").expect("build image marker fixture");
610 let document = document_with_assets(vec![block], [(asset_id, asset)]);
611 ensure_no_unresolved_image_markers("astro", AssetStrategy::Copy, &document)
612 .expect("copy strategy should not enforce marker guard");
613 }
614
615 #[test]
616 fn test_ensure_no_unresolved_image_markers_errors_for_deferred_strategy() {
617 let (block, (asset_id, asset)) =
618 image_marker("asset-a", "assets/a.png", "").expect("build image marker fixture");
619 let document = document_with_assets(vec![block], [(asset_id, asset)]);
620 let err =
621 ensure_no_unresolved_image_markers("confluence", AssetStrategy::Upload, &document)
622 .expect_err("deferred strategy should fail on unresolved local assets");
623 assert!(err.to_string().contains("unresolved local asset"));
624 }
625
626 #[test]
627 fn test_adapter_registry_with_default_config() {
628 let config = Config::default();
632 let registry = AdapterRegistry::new(&config).expect("create registry");
633 let list = registry.list();
634 assert!(list.contains(&"wechat"));
636 assert!(list.contains(&"zhihu"));
637 assert!(list.contains(&"csdn"));
638 assert!(list.contains(&"astro"));
640 assert!(list.contains(&"static"));
641 assert!(list.contains(&"xiaohongshu"));
642 assert!(!list.contains(&"devto"));
644 assert!(!list.contains(&"ghost"));
645 }
646
647 #[test]
648 fn test_adapter_registry_get_nonexistent() {
649 let config = Config::default();
650 let registry = AdapterRegistry::new(&config).expect("create registry");
651 match registry.get("nonexistent") {
652 Ok(_) => panic!("expected error for nonexistent platform"),
653 Err(e) => assert!(e.to_string().contains("not found")),
654 }
655 }
656
657 #[test]
658 fn test_adapter_registry_explicit_disable() {
659 let config: Config = toml::from_str(
660 r#"
661[platforms.wechat]
662enabled = false
663"#,
664 )
665 .expect("parse config");
666 let registry = AdapterRegistry::new(&config).expect("create registry");
667 assert!(!registry.list().contains(&"wechat"));
669 assert!(registry.list().contains(&"zhihu"));
671 }
672
673 #[test]
675 fn test_adapter_registry_requires_config_false_can_be_disabled() {
676 let config: Config = toml::from_str(
677 r#"
678[platforms.astro]
679enabled = false
680"#,
681 )
682 .expect("parse config");
683 let registry = AdapterRegistry::new(&config).expect("create registry");
684 assert!(!registry.list().contains(&"astro"));
686 assert!(registry.list().contains(&"static"));
688 assert!(registry.list().contains(&"xiaohongshu"));
689 }
690
691 #[test]
693 fn test_adapter_registry_requires_config_true_registered_when_enabled() {
694 let config: Config = toml::from_str(
695 r#"
696[platforms.devto]
697enabled = true
698"#,
699 )
700 .expect("parse config");
701 let registry = AdapterRegistry::new(&config).expect("create registry");
702 assert!(registry.list().contains(&"devto"));
704 assert!(!registry.list().contains(&"ghost"));
706 assert!(!registry.list().contains(&"wordpress"));
707 }
708
709 #[test]
711 fn test_adapter_registry_requires_config_true_not_registered_when_disabled() {
712 let config: Config = toml::from_str(
713 r#"
714[platforms.ghost]
715enabled = false
716"#,
717 )
718 .expect("parse config");
719 let registry = AdapterRegistry::new(&config).expect("create registry");
720 assert!(!registry.list().contains(&"ghost"));
722 }
723
724 #[test]
726 fn test_adapter_capability_requires_config_values() {
727 let astro = adapter_capability("astro").expect("astro exists");
729 assert!(!astro.requires_config);
730
731 let static_cap = adapter_capability("static").expect("static exists");
732 assert!(!static_cap.requires_config);
733
734 let xiaohongshu = adapter_capability("xiaohongshu").expect("xiaohongshu exists");
735 assert!(!xiaohongshu.requires_config);
736
737 let devto = adapter_capability("devto").expect("devto exists");
739 assert!(devto.requires_config);
740
741 let ghost = adapter_capability("ghost").expect("ghost exists");
742 assert!(ghost.requires_config);
743
744 let wordpress = adapter_capability("wordpress").expect("wordpress exists");
745 assert!(wordpress.requires_config);
746
747 let hashnode = adapter_capability("hashnode").expect("hashnode exists");
748 assert!(hashnode.requires_config);
749
750 let confluence = adapter_capability("confluence").expect("confluence exists");
751 assert!(confluence.requires_config);
752
753 let notion = adapter_capability("notion").expect("notion exists");
754 assert!(notion.requires_config);
755 }
756}