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