publishmd 0.1.0__tar.gz

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.
Files changed (32) hide show
  1. publishmd-0.1.0/PKG-INFO +79 -0
  2. publishmd-0.1.0/README.md +58 -0
  3. publishmd-0.1.0/pyproject.toml +49 -0
  4. publishmd-0.1.0/setup.cfg +4 -0
  5. publishmd-0.1.0/src/publishmd/__init__.py +3 -0
  6. publishmd-0.1.0/src/publishmd/base.py +67 -0
  7. publishmd-0.1.0/src/publishmd/cli.py +70 -0
  8. publishmd-0.1.0/src/publishmd/config.py +146 -0
  9. publishmd-0.1.0/src/publishmd/emitters/__init__.py +1 -0
  10. publishmd-0.1.0/src/publishmd/emitters/assets_emitter.py +271 -0
  11. publishmd-0.1.0/src/publishmd/emitters/qmd_emitter.py +128 -0
  12. publishmd-0.1.0/src/publishmd/filters/__init__.py +6 -0
  13. publishmd-0.1.0/src/publishmd/filters/frontmatter_filter.py +83 -0
  14. publishmd-0.1.0/src/publishmd/processor.py +118 -0
  15. publishmd-0.1.0/src/publishmd/transformers/__init__.py +1 -0
  16. publishmd-0.1.0/src/publishmd/transformers/stale_links_transformer.py +231 -0
  17. publishmd-0.1.0/src/publishmd/transformers/tags_to_categories_transformer.py +110 -0
  18. publishmd-0.1.0/src/publishmd/transformers/wikilink_transformer.py +241 -0
  19. publishmd-0.1.0/src/publishmd.egg-info/PKG-INFO +79 -0
  20. publishmd-0.1.0/src/publishmd.egg-info/SOURCES.txt +30 -0
  21. publishmd-0.1.0/src/publishmd.egg-info/dependency_links.txt +1 -0
  22. publishmd-0.1.0/src/publishmd.egg-info/entry_points.txt +2 -0
  23. publishmd-0.1.0/src/publishmd.egg-info/requires.txt +6 -0
  24. publishmd-0.1.0/src/publishmd.egg-info/top_level.txt +1 -0
  25. publishmd-0.1.0/tests/test_assets_emitter.py +447 -0
  26. publishmd-0.1.0/tests/test_config.py +133 -0
  27. publishmd-0.1.0/tests/test_filters.py +257 -0
  28. publishmd-0.1.0/tests/test_processor_filtering.py +207 -0
  29. publishmd-0.1.0/tests/test_qmd_emitter.py +132 -0
  30. publishmd-0.1.0/tests/test_stale_links_transformer.py +350 -0
  31. publishmd-0.1.0/tests/test_tags_to_categories_transformer.py +260 -0
  32. publishmd-0.1.0/tests/test_wikilink_transformer.py +389 -0
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: publishmd
3
+ Version: 0.1.0
4
+ Summary: Prepare markdown content for publication with configurable processing pipeline
5
+ Author: Mateus Molina
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/MateusMolina/publishmd
8
+ Project-URL: Repository, https://github.com/MateusMolina/publishmd.git
9
+ Project-URL: Issues, https://github.com/MateusMolina/publishmd/issues
10
+ Keywords: quarto,markdown,publishing,second-brain,notes,vault,obsidian,journal
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Office/Business
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: pyyaml>=6.0
17
+ Requires-Dist: click>=8.0
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest; extra == "dev"
20
+ Requires-Dist: black; extra == "dev"
21
+
22
+ # publishmd
23
+
24
+ Prepare markdown content for publication with configurable processing pipeline.
25
+
26
+ ***Use Case 1.*** Transform an Obsidian vault into publication-ready content for a Quarto blog. Convert wikilinks, filter content, copy assets, and apply transformations to prepare your notes for publishing.
27
+
28
+ ## Installation
29
+
30
+ ### From Source
31
+
32
+ ```bash
33
+ pip install -e .
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ publishmd -c config.yaml -i /path/to/markdown -o /path/to/output
40
+ ```
41
+
42
+ ## Configuration
43
+
44
+ Create a YAML configuration file to specify the processing pipeline, e.g.:
45
+
46
+ ```yaml
47
+ filters:
48
+ - name: frontmatter_filter
49
+ type: publishmd.filters.frontmatter_filter.FrontmatterFilter
50
+ config:
51
+ publish: true
52
+
53
+ emitters:
54
+ - name: qmd_emitter
55
+ type: publishmd.emitters.qmd_emitter.QmdEmitter
56
+ - name: assets_emitter
57
+ type: publishmd.emitters.assets_emitter.AssetsEmitter
58
+
59
+ transformers:
60
+ - name: wikilink_transformer
61
+ type: publishmd.transformers.wikilink_transformer.WikilinkTransformer
62
+ config:
63
+ preserve_aliases: true
64
+ link_extension: ".qmd"
65
+ - name: stale_links_transformer
66
+ type: publishmd.transformers.stale_links_transformer.StaleLinksTransformer
67
+ config:
68
+ remove_stale_links: true
69
+ convert_to_text: true
70
+ ```
71
+
72
+ For more examples, please check the integration tests folder (tests/integration).
73
+
74
+ ## Development
75
+
76
+ ```bash
77
+ pip install -e ".[dev]"
78
+ pytest
79
+ ```
@@ -0,0 +1,58 @@
1
+ # publishmd
2
+
3
+ Prepare markdown content for publication with configurable processing pipeline.
4
+
5
+ ***Use Case 1.*** Transform an Obsidian vault into publication-ready content for a Quarto blog. Convert wikilinks, filter content, copy assets, and apply transformations to prepare your notes for publishing.
6
+
7
+ ## Installation
8
+
9
+ ### From Source
10
+
11
+ ```bash
12
+ pip install -e .
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ```bash
18
+ publishmd -c config.yaml -i /path/to/markdown -o /path/to/output
19
+ ```
20
+
21
+ ## Configuration
22
+
23
+ Create a YAML configuration file to specify the processing pipeline, e.g.:
24
+
25
+ ```yaml
26
+ filters:
27
+ - name: frontmatter_filter
28
+ type: publishmd.filters.frontmatter_filter.FrontmatterFilter
29
+ config:
30
+ publish: true
31
+
32
+ emitters:
33
+ - name: qmd_emitter
34
+ type: publishmd.emitters.qmd_emitter.QmdEmitter
35
+ - name: assets_emitter
36
+ type: publishmd.emitters.assets_emitter.AssetsEmitter
37
+
38
+ transformers:
39
+ - name: wikilink_transformer
40
+ type: publishmd.transformers.wikilink_transformer.WikilinkTransformer
41
+ config:
42
+ preserve_aliases: true
43
+ link_extension: ".qmd"
44
+ - name: stale_links_transformer
45
+ type: publishmd.transformers.stale_links_transformer.StaleLinksTransformer
46
+ config:
47
+ remove_stale_links: true
48
+ convert_to_text: true
49
+ ```
50
+
51
+ For more examples, please check the integration tests folder (tests/integration).
52
+
53
+ ## Development
54
+
55
+ ```bash
56
+ pip install -e ".[dev]"
57
+ pytest
58
+ ```
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "publishmd"
7
+ version = "0.1.0"
8
+ description = "Prepare markdown content for publication with configurable processing pipeline"
9
+ authors = [{name = "Mateus Molina"}]
10
+ license = "MIT"
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Office/Business",
15
+ ]
16
+ keywords = ["quarto", "markdown", "publishing", "second-brain", "notes", "vault", "obsidian", "journal"]
17
+ readme = "README.md"
18
+ requires-python = ">=3.10"
19
+ dependencies = [
20
+ "pyyaml>=6.0",
21
+ "click>=8.0",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest",
27
+ "black"
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/MateusMolina/publishmd"
32
+ Repository = "https://github.com/MateusMolina/publishmd.git"
33
+ Issues = "https://github.com/MateusMolina/publishmd/issues"
34
+
35
+ [project.scripts]
36
+ publishmd = "publishmd.cli:main"
37
+
38
+ [tool.setuptools.packages.find]
39
+ where = ["src"]
40
+
41
+ [tool.setuptools.package-dir]
42
+ "" = "src"
43
+
44
+ [tool.pytest.ini_options]
45
+ markers = [
46
+ "unit: marks tests as unit tests (fast, isolated)",
47
+ "integration: marks tests as integration tests (slower, end-to-end)",
48
+ "slow: marks tests as slow running",
49
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """publishmd - Prepare markdown content for publication."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,67 @@
1
+ """Base classes for emitters, transformers, and filters."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Set
6
+
7
+
8
+ class Emitter(ABC):
9
+ """Base class for all emitters."""
10
+
11
+ def __init__(self, config: Dict[str, Any]):
12
+ """Initialize the emitter with configuration."""
13
+ self.config = config
14
+
15
+ @abstractmethod
16
+ def emit(self, files_to_process: List[Path], output_dir: Path) -> List[Path]:
17
+ """
18
+ Emit files to output directory.
19
+
20
+ Args:
21
+ files_to_process: List of files to process and emit
22
+ output_dir: Target directory for emitted files
23
+
24
+ Returns:
25
+ List of paths to emitted files
26
+ """
27
+ pass
28
+
29
+
30
+ class Transformer(ABC):
31
+ """Base class for all transformers."""
32
+
33
+ def __init__(self, config: Dict[str, Any]):
34
+ """Initialize the transformer with configuration."""
35
+ self.config = config
36
+
37
+ @abstractmethod
38
+ def transform(self, file_path: Path, emitted_files: List[Path]) -> None:
39
+ """
40
+ Transform a file in place.
41
+
42
+ Args:
43
+ file_path: Path to the file to transform
44
+ emitted_files: List of all emitted files for reference
45
+ """
46
+ pass
47
+
48
+
49
+ class Filter(ABC):
50
+ """Base class for all filters."""
51
+
52
+ def __init__(self, config: Dict[str, Any]):
53
+ """Initialize the filter with configuration."""
54
+ self.config = config
55
+
56
+ @abstractmethod
57
+ def should_include(self, file_path: Path) -> bool:
58
+ """
59
+ Check if a file should be included based on filter criteria.
60
+
61
+ Args:
62
+ file_path: Path to the file to check
63
+
64
+ Returns:
65
+ True if the file should be included, False otherwise
66
+ """
67
+ pass
@@ -0,0 +1,70 @@
1
+ """Command-line interface for publishmd."""
2
+
3
+ import click
4
+ from pathlib import Path
5
+ from typing import Dict, Any
6
+
7
+ from .processor import Processor
8
+
9
+
10
+ @click.command()
11
+ @click.option(
12
+ "--config",
13
+ "-c",
14
+ type=click.Path(exists=True, path_type=Path),
15
+ required=True,
16
+ help="Path to the YAML configuration file",
17
+ )
18
+ @click.option(
19
+ "--input-dir",
20
+ "-i",
21
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
22
+ required=True,
23
+ help="Input directory containing markdown files",
24
+ )
25
+ @click.option(
26
+ "--output-dir",
27
+ "-o",
28
+ type=click.Path(path_type=Path),
29
+ required=True,
30
+ help="Output directory for processed files",
31
+ )
32
+ @click.option(
33
+ "--verbose",
34
+ "-v",
35
+ is_flag=True,
36
+ help="Enable verbose output",
37
+ )
38
+ def main(config: Path, input_dir: Path, output_dir: Path, verbose: bool) -> None:
39
+ """Prepare markdown content for publication."""
40
+
41
+ if verbose:
42
+ click.echo(f"Loading configuration from: {config}")
43
+ click.echo(f"Input directory: {input_dir}")
44
+ click.echo(f"Output directory: {output_dir}")
45
+
46
+ try:
47
+ # Create CLI overrides dictionary
48
+ cli_overrides: Dict[str, Any] = {
49
+ "verbose": verbose,
50
+ }
51
+
52
+ # Initialize processor
53
+ processor = Processor(config, cli_overrides)
54
+
55
+ if verbose:
56
+ click.echo(f"Loaded {len(processor.emitters)} emitters")
57
+ click.echo(f"Loaded {len(processor.transformers)} transformers")
58
+
59
+ # Process files
60
+ processor.process(input_dir, output_dir)
61
+
62
+ click.echo("Conversion completed successfully!")
63
+
64
+ except Exception as e:
65
+ click.echo(f"Error: {e}", err=True)
66
+ raise click.Abort()
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -0,0 +1,146 @@
1
+ """Configuration loading and plugin management."""
2
+
3
+ import importlib
4
+ import yaml
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Union
7
+
8
+ from .base import Emitter, Transformer, Filter
9
+
10
+
11
+ class ConfigLoader:
12
+ """Loads and validates configuration from YAML files."""
13
+
14
+ @staticmethod
15
+ def load_config(config_path: Union[str, Path]) -> Dict[str, Any]:
16
+ """
17
+ Load configuration from a YAML file.
18
+
19
+ Args:
20
+ config_path: Path to the YAML configuration file
21
+
22
+ Returns:
23
+ Dictionary containing the configuration
24
+ """
25
+ config_path = Path(config_path)
26
+ if not config_path.exists():
27
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
28
+
29
+ with open(config_path, "r", encoding="utf-8") as f:
30
+ config = yaml.safe_load(f)
31
+
32
+ return config or {}
33
+
34
+ @staticmethod
35
+ def validate_config(config: Dict[str, Any]) -> None:
36
+ """
37
+ Validate the configuration structure.
38
+
39
+ Args:
40
+ config: Configuration dictionary to validate
41
+
42
+ Raises:
43
+ ValueError: If configuration is invalid
44
+ """
45
+ if not isinstance(config, dict):
46
+ raise ValueError("Configuration must be a dictionary")
47
+
48
+ # At least one of these sections must be present
49
+ required_sections = ["emitters", "transformers", "filters"]
50
+ if not any(section in config for section in required_sections):
51
+ raise ValueError(
52
+ f"Configuration must contain at least one of: {', '.join(required_sections)}"
53
+ )
54
+
55
+ for section in ["emitters", "transformers", "filters"]:
56
+ if section in config:
57
+ if not isinstance(config[section], list):
58
+ raise ValueError(f"'{section}' must be a list")
59
+
60
+ for item in config[section]:
61
+ if not isinstance(item, dict):
62
+ raise ValueError(
63
+ f"Each item in '{section}' must be a dictionary"
64
+ )
65
+
66
+ if "name" not in item:
67
+ raise ValueError(f"Each item in '{section}' must have a 'name'")
68
+
69
+ if "type" not in item:
70
+ raise ValueError(f"Each item in '{section}' must have a 'type'")
71
+
72
+
73
+ class PluginLoader:
74
+ """Loads and instantiates plugins from configuration."""
75
+
76
+ @staticmethod
77
+ def load_plugin_class(class_path: str) -> type:
78
+ """
79
+ Load a plugin class from a module path.
80
+
81
+ Args:
82
+ class_path: Full module path to the class (e.g., 'module.submodule.ClassName')
83
+
84
+ Returns:
85
+ The loaded class
86
+ """
87
+ module_path, class_name = class_path.rsplit(".", 1)
88
+ module = importlib.import_module(module_path)
89
+ return getattr(module, class_name)
90
+
91
+ @staticmethod
92
+ def load_emitters(config: List[Dict[str, Any]]) -> List[Emitter]:
93
+ """
94
+ Load emitter instances from configuration.
95
+
96
+ Args:
97
+ config: List of emitter configurations
98
+
99
+ Returns:
100
+ List of instantiated emitters
101
+ """
102
+ emitters = []
103
+ for emitter_config in config:
104
+ plugin_class = PluginLoader.load_plugin_class(emitter_config["type"])
105
+ plugin_config = emitter_config.get("config", {})
106
+ emitter = plugin_class(plugin_config)
107
+ emitters.append(emitter)
108
+ return emitters
109
+
110
+ @staticmethod
111
+ def load_transformers(config: List[Dict[str, Any]]) -> List[Transformer]:
112
+ """
113
+ Load transformer instances from configuration.
114
+
115
+ Args:
116
+ config: List of transformer configurations
117
+
118
+ Returns:
119
+ List of instantiated transformers
120
+ """
121
+ transformers = []
122
+ for transformer_config in config:
123
+ plugin_class = PluginLoader.load_plugin_class(transformer_config["type"])
124
+ plugin_config = transformer_config.get("config", {})
125
+ transformer = plugin_class(plugin_config)
126
+ transformers.append(transformer)
127
+ return transformers
128
+
129
+ @staticmethod
130
+ def load_filters(config: List[Dict[str, Any]]) -> List[Filter]:
131
+ """
132
+ Load filter instances from configuration.
133
+
134
+ Args:
135
+ config: List of filter configurations
136
+
137
+ Returns:
138
+ List of instantiated filters
139
+ """
140
+ filters = []
141
+ for filter_config in config:
142
+ plugin_class = PluginLoader.load_plugin_class(filter_config["type"])
143
+ plugin_config = filter_config.get("config", {})
144
+ filter_instance = plugin_class(plugin_config)
145
+ filters.append(filter_instance)
146
+ return filters
@@ -0,0 +1 @@
1
+ """Emitters package."""