├── .circleci └── config.yml ├── .formatter.exs ├── .gitignore ├── LICENSE.txt ├── README.md ├── assets └── functionhaus_logo.png ├── lib ├── archivist.ex └── archivist │ ├── archive.ex │ ├── article.ex │ ├── enum_utils.ex │ ├── map_utils.ex │ └── parsers │ └── article_parser.ex ├── mix.exs ├── mix.lock ├── priv └── archive │ ├── articles │ ├── Fiction │ │ └── Sci-Fi │ │ │ └── Classic │ │ │ └── journey_to_the_center_of_the_earth.md.ad │ └── Films │ │ ├── Action │ │ └── Crime │ │ │ └── heat.md.ad │ │ └── Sci-Fi │ │ └── Classic │ │ └── the_day_the_earth_stood_still.ad │ └── images │ ├── 2001.jpg │ ├── big_lebowski.png │ ├── chameleon.jpg │ └── michael.gif └── test ├── archivist ├── duplicate_slugs_test.exs ├── local_archive_test.exs ├── remote_archive_test.exs └── unparsed_content_test.exs ├── archivist_test.exs ├── support ├── archives │ ├── duplicate_slugs │ │ └── articles │ │ │ ├── the_day_the_earth_stood_still.ad │ │ │ ├── the_day_the_earth_stood_still2.ad │ │ │ └── the_day_the_earth_stood_still3.ad │ └── local │ │ ├── articles │ │ ├── Fiction │ │ │ └── Sci-Fi │ │ │ │ └── Classic │ │ │ │ └── journey_to_the_center_of_the_earth.md.ad │ │ └── Films │ │ │ ├── Action │ │ │ └── Crime │ │ │ │ └── heat.md.ad │ │ │ └── Sci-Fi │ │ │ └── Classic │ │ │ └── the_day_the_earth_stood_still.ad │ │ └── images │ │ ├── 2001.jpg │ │ ├── big_lebowski.png │ │ ├── chameleon.jpg │ │ └── michael.gif ├── dup_slugs_archive.ex ├── local_archive.ex ├── remote_archive.ex └── unparsed_content_archive.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Elixir CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-elixir/ for more details 4 | version: 2 5 | jobs: 6 | build: 7 | docker: 8 | # specify the version here 9 | - image: circleci/elixir:1.9 10 | environment: 11 | MIX_ENV: test 12 | 13 | working_directory: ~/archivist 14 | steps: 15 | - checkout 16 | 17 | ## Mix Config 18 | 19 | # specify any bash command here prefixed with `run: ` 20 | - run: mix local.hex --force 21 | - run: mix local.rebar --force 22 | 23 | # load mix deps from cache if they exist and nothing changed 24 | # load node modules from cache if nothing changed 25 | - restore_cache: 26 | keys: 27 | - mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }} 28 | - mix-cache-{{ .Branch }} 29 | - mix-cache 30 | 31 | # fetch dependencies 32 | - run: mix deps.get 33 | 34 | # save cache changes for future runs 35 | - save_cache: 36 | key: mix-cache-{{ .Branch }}-{{ checksum "mix.lock" }} 37 | paths: 38 | - ./_build 39 | - db 40 | - deps 41 | - ./*.ez 42 | - save_cache: 43 | key: mix-cache-{{ .Branch }} 44 | paths: 45 | - ./_build 46 | - db 47 | - deps 48 | - ./*.ez 49 | - save_cache: 50 | key: mix-cache 51 | paths: 52 | - ./_build 53 | - db 54 | - deps 55 | - ./*.ez 56 | 57 | # run the tests 58 | - run: mix test 59 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | archivist-*.tar 24 | 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 FunctionHaus, LLC 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archivist 2 | 3 | [![CircleCI](https://circleci.com/gh/functionhaus/archivist.svg?style=svg)](https://circleci.com/gh/functionhaus/archivist) 4 | 5 | Archivist is a straightforward blogging utility for generating article content 6 | at compile time from version-controlled article and image files. It is built to 7 | be used in conjunction with the [Arcdown plaintext article parser library](https://github.com/functionhaus/arcdown). 8 | 9 | Archivist is inspired by the general approach of Cẩm Huỳnh's great 10 | [Nabo](https://github.com/qcam/nabo) library with some key differences: 11 | 12 | * Articles are formatted in `Arcdown` format by default, allowing for more robust articles and article features. 13 | 14 | * Content parsing and sorting mechanisms are exposed as anonymous functions, easiliy exposing custom functionality. 15 | 16 | * Articles can be organized into nested *topic* directories for better organization. Topics are parsed in a hierarchical structure. 17 | 18 | * Use of an "intermediate library pattern" is supported, allowing content and articles to be stored in a dedicated library and separate repository. 19 | 20 | * Default attributes are included for both author names and email addresses 21 | 22 | * `created_at` and `published_at` timestamps are permitted 23 | 24 | * Flexible *tags* can be applied as desired to any article 25 | 26 | * Custom content constraints throw warnings during compilation if violated 27 | 28 | * Slug uniqueness is enforced by default and triggers compile-time warnings 29 | 30 | * Image files can be stored alongside articles, and accessed with helpers 31 | 32 | ## Installation 33 | 34 | The package can be installed by adding `archivist` to your list of 35 | dependencies in `mix.exs`: 36 | 37 | ```elixir 38 | def deps do 39 | [ 40 | {:archivist, "~> 0.3"} 41 | ] 42 | end 43 | ``` 44 | 45 | ## Usage 46 | 47 | The heart of Archivist is the `Archive` module, which acts as a repository for 48 | exposing query functions for articles, slugs, topics, etc. You can create an 49 | Archive out of any Elixir module by using `Archivist.Archive` like this: 50 | 51 | ```elixir 52 | defmodule MyApp.Archive 53 | use Archivist.Archive 54 | end 55 | 56 | # this alias is just a nicety, not required 57 | alias MyApp.Archive 58 | 59 | Archive.articles() 60 | Archive.topics() # hierarchical topics 61 | Archive.topics_list() # flattened topics and sub-topics 62 | Archive.tags() 63 | Archive.slugs() 64 | Archive.authors() 65 | ``` 66 | 67 | Additionally Archvist exposes helpers for reading paths for articles and 68 | image files: 69 | 70 | ```elixir 71 | Archive.article_paths() 72 | Archive.image_paths() 73 | ``` 74 | 75 | Archivist 0.3.x and 0.2.x versions expect you to create your article content directory at 76 | `priv/archive/articles` at the root of your elixir library, like this: 77 | 78 | `priv/archive/articles/journey_to_the_center_of_the_earth.ad` 79 | 80 | If you'd like to customize any of your archive's behavior, you can define any of 81 | the following options when it is used in the target archive directory. The values 82 | shown are the defaults: 83 | 84 | ```elixir 85 | defmodule MyApp.Archive 86 | use Archivist.Archive, 87 | archive_dir: "priv/archive", 88 | content_dir: "articles", 89 | content_pattern: "**/*.ad", 90 | image_dir: "images", 91 | image_pattern: "**/*.{jpg,gif,png}", 92 | article_sorter: &(Map.get(&1, :published_at) >= Map.get(&2, :published_at)), 93 | article_parser: &Arcdown.parse_file(&1), 94 | content_parser: &Earmark.as_html!(&1), 95 | slug_warnings: true, 96 | application: nil, 97 | valid_tags: nil, 98 | valid_topics: nil, 99 | valid_authors: nil 100 | end 101 | ``` 102 | 103 | Archivist will read any files with the `.ad` extension in your content directory 104 | or in any of its subdirectories, and parse the content of those files with the 105 | parser you've selected (Arcdown by default) 106 | 107 | If you'd like to store your archive somewhere besides `priv/archive` you can 108 | assign a custom path to your archive like this: 109 | 110 | ```elixir 111 | defmodule MyApp.Archive 112 | use Archivist.Archive, archive_dir: "assets/archive", 113 | end 114 | ``` 115 | 116 | ## Arcdown 117 | 118 | Arcdown supports the following features for articles: 119 | 120 | * Article Content 121 | * Article Summary 122 | * Topics 123 | * Sub-Topics 124 | * Tags 125 | * Published Datetime 126 | * Creation Datetime 127 | * Author Name 128 | * Author Email 129 | * Article Slug 130 | 131 | Here is an example article written in *Arcdown (.ad)* format: 132 | 133 | ``` 134 | The Day the Earth Stood Still 135 | by Julian Blaustein 136 | 137 | Filed under: Films > Sci-Fi > Classic 138 | 139 | Created @ 10:24pm on 1/20/2019 140 | Published @ 10:20pm on 1/20/2019 141 | 142 | * Sci-Fi 143 | * Horror 144 | * Thrillers 145 | * Aliens 146 | 147 | Summary: 148 | A sci-fi classic about a flying saucer landing in Washington, D.C. 149 | 150 | --- 151 | 152 | The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the 153 | World) is a 1951 American black-and-white science fiction film from 20th Century 154 | Fox, produced by Julian Blaustein and directed by Robert Wise. 155 | ``` 156 | 157 | By default Archivist will parse and return article content as 158 | `Archivist.Article` structs. The parsing output of the above article example 159 | would look like this: 160 | 161 | ```elixir 162 | %Archivist.Article{ 163 | author: "Julian Blaustein", 164 | content: "The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the\nWorld) is a 1951 American black-and-white science fiction film from 20th Century\nFox, produced by Julian Blaustein and directed by Robert Wise.\n", 165 | parsed_content: "

The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the\nWorld) is a 1951 American black-and-white science fiction film from 20th Century\nFox, produced by Julian Blaustein and directed by Robert Wise.

\n", 166 | created_at: #DateTime<2019-01-20 22:24:00Z>, 167 | email: "julian@blaustein.com", 168 | published_at: #DateTime<2019-04-02 04:30:00Z>, 169 | slug: "the-day-the-earth-stood-still", 170 | summary: "A sci-fi classic about a flying saucer landing in Washington, D.C.", 171 | tags: [:sci_fi, :horror, :thrillers, :aliens], 172 | title: "The Day the Earth Stood Still", 173 | topics: ["Films", "Sci-Fi", "Classic"] 174 | } 175 | ``` 176 | ## Intermediate Library Pattern 177 | 178 | While it's completely acceptable use Archivist.Archive within the same 179 | application in which the content archive is located, sites with lots of content 180 | and publishers who commit changes frequently will quickly find the git 181 | history for their application littered with content-related commits that have 182 | nothing to do with the broader functionality of the application itself. 183 | 184 | To remedy this issue, Archivist permits and encourages the use of an 185 | intermediate library to house the content archive (`myapp_blog` for example), 186 | and then to include that intermediate library in the target application where 187 | the content is being used and displayed. 188 | 189 | This approach requires you generate a new mix library with `mix new myapp_blog`, 190 | and then to publish that repository so that it's available to other Elixir and 191 | Erlang applications, via hex.pm or hex.pm organizations for example. 192 | 193 | The preferred way to implement this approach is to include `archivist` as a 194 | dependency in your intermediate library (rather than in your application), and 195 | then to create a new Archive in your intermediate library like this: 196 | 197 | ```elixir 198 | defmodule MyappBlog.Archive do 199 | use Archivist.Archive, 200 | application: :myapp_blog, 201 | archive_dir: "archive" 202 | end 203 | ``` 204 | 205 | Note that this approach requires you to add the name of your otp application in 206 | the `application` flag when your archive is defined. Also note that `archive_dir` 207 | is compressed to just `archive` instead of `priv/archive` since this approach 208 | automatically assumes that content will be stored in the `priv` directory of the 209 | otp app indicated by the `application` option. 210 | 211 | Here is an example of an excerpt from the mixfile of an intermediate library: 212 | 213 | ```elixir 214 | defp deps do 215 | [ 216 | {:archivist, "~> 0.3"}, 217 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 218 | ] 219 | end 220 | 221 | defp package do 222 | [ 223 | files: ["lib", "priv", "mix.exs", "README.md"], 224 | organization: "my_hex_org" 225 | ] 226 | end 227 | ``` 228 | 229 | Requiring the priv dir here is essential to ensuring that the content archive is 230 | packaged with the hex release, and is then made available for the target 231 | application. 232 | 233 | Setting the organization here scopes the published package to a hex 234 | organization, thus ensuring that it remains private. 235 | 236 | Then in the application where your content is being used, be sure to include the 237 | intermediate library as a dependency: 238 | 239 | ```elixir 240 | defp deps do 241 | [ 242 | ... 243 | {:myapp_blog, "~> 0.1", organization: "my_hex_org"} 244 | ] 245 | end 246 | ``` 247 | 248 | And then you should be able to use your content directly in your application: 249 | 250 | ```elixir 251 | MyappBlog.Archive.articles() 252 | MyappBlog.Archive.topics() 253 | ``` 254 | 255 | ## Parsed Content Constraints 256 | 257 | As of Archivist version `0.2.6` archives can receive flags for lists of 258 | `valid_topics` and `valid_tags`. Version `0.2.9` added support for 259 | `valid_authors` constraints. Here are some examples of constraints: 260 | 261 | ```elixir 262 | defmodule Myapp.Archive do 263 | use Archivist, 264 | valid_topics: [ 265 | "Action", 266 | "Classic", 267 | "Crime", 268 | "Fiction", 269 | "Films", 270 | "Sci-Fi" 271 | ], 272 | valid_tags: [ 273 | :action, 274 | :adventure, 275 | :aliens, 276 | :crime, 277 | :horror, 278 | :literature, 279 | :modern_classic, 280 | :sci_fi, 281 | :thrillers 282 | ], 283 | valid_authors: [ 284 | "Jules Verne", 285 | "Julian Blaustein", 286 | "Michael Mann" 287 | ] 288 | end 289 | ``` 290 | 291 | Adding articles with tags, topics or authors that don't conform to these lists, 292 | or using a topic directory structure that doesn't conform to these lists will 293 | throw warnings at compile time, like this: 294 | 295 | ```elixir 296 | warning: Archivist Archive contains invalid topics: Action, Classic 297 | (archivist) lib/archivist/parsers/article_parser.ex:77: Archivist.ArticleParser.warn_invalid/3 298 | 299 | warning: Archivist Archive contains invalid tags: action, adventure 300 | (archivist) lib/archivist/parsers/article_parser.ex:77: Archivist.ArticleParser.warn_invalid/3 301 | 302 | warning: Archivist Archive contains invalid authors: Ernest Hemingway 303 | (archivist) lib/archivist/parsers/article_parser.ex:77: Archivist.ArticleParser.warn_invalid/3 304 | ``` 305 | 306 | Compilation will not cease, however, simply because these constraints are 307 | being violated. 308 | 309 | Please note that only exact topic and author matches are accounted for here, 310 | so`"Sci-Fi"` will not be considered equivalent to `"SciFi"` and will throw a 311 | warning. Similarly, "J.D. Salinger" will not be considered to be the same 312 | author as "JD Salinger" by the article parser. 313 | 314 | If you do not want warnings for tags, topics or authors during compilation 315 | simply don't declare any values for `valid_topics`, `valid_tags`, or 316 | `valid_authors` depending on your desired outcomes, and they'll be ignored. 317 | 318 | Also note that enforcement of valid topics currently is only compared to the 319 | flattened list of topics and sub-topics. There is no functionality in place 320 | at the moment for constraining specific topic hierarchies. 321 | 322 | It should additionally be noted that the `slug_warnings` filter is on by 323 | default, meaning that the parser will throw warnings if duplicate slugs are 324 | found across articles in your content archive. This can be turned off by 325 | setting `slug_warnings: false` when you declare your archive, like this: 326 | 327 | 328 | ```elixir 329 | defmodule Myapp.Archive do 330 | use Archivist, slug_warnings: false 331 | end 332 | ``` 333 | 334 | ## Mounting Images with Plug 335 | 336 | If you choose to store images with your archive, it's probably most useful to 337 | have that content mounted as a static assets path somewhere where the content 338 | can be digested with Webpack or whichever assets manager you're using. 339 | 340 | For systems built with Plug (including Phoenix), it's easy enough to mount the 341 | images path with `Plug.Static` at the path of your choice. Simply call the name 342 | of the otp app where your content is stored along with the path to the images: 343 | 344 | ```elixir 345 | plug Plug.Static, 346 | at: "/blog/images/", 347 | from: {:myapp_blog, "priv/archive/images"} 348 | ``` 349 | 350 | Note that for applications that employed the Intermediate Library Pattern, the 351 | flags for `Plug.Static` will look like the example above. For instances where 352 | Archivist is being used directly in the target application, the name of the 353 | current application should be used here, like this: 354 | 355 | ```elixir 356 | plug Plug.Static, 357 | at: "/blog/images/", 358 | from: {:myapp, "priv/archive/images"} 359 | ``` 360 | 361 | For use within Phoenix in particular, your plug declaration would likely go in 362 | the `MyappWeb.Endpoint` module, like this: 363 | 364 | ```elixir 365 | defmodule MyappWeb.Endpoint do 366 | use Phoenix.Endpoint, otp_app: :myapp 367 | 368 | # app name here will be :myapp or :myapp_blog depending on which otp app 369 | # contains the content archive 370 | plug Plug.Static, 371 | at: "/blog/images/", 372 | from: {:myapp_blog, "priv/archive/images"} 373 | ``` 374 | 375 | ## Development Notes 376 | 377 | Please find additional information about known issues and planned features for 378 | Archivist in the [issues tracker](https://github.com/functionhaus/archivist/issues). 379 | 380 | ## Todo 381 | 382 | Issues and Todo enhancements are managed at the official 383 | [Archivist issues tracker](https://github.com/functionhaus/archivist/issues) on GitHub. 384 | 385 | ## Availability 386 | 387 | Source code is available at the official 388 | [Archivist repository](https://github.com/functionhaus/arcdown) 389 | on the [FunctionHaus GitHub Organization](https://github.com/functionhaus) 390 | 391 | ## License 392 | 393 | Archivist source code is released under Apache 2 License. 394 | Check LICENSE file for more information. 395 | 396 | © 2017 FunctionHaus, LLC 397 | -------------------------------------------------------------------------------- /assets/functionhaus_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/assets/functionhaus_logo.png -------------------------------------------------------------------------------- /lib/archivist.ex: -------------------------------------------------------------------------------- 1 | defmodule Archivist do 2 | end 3 | -------------------------------------------------------------------------------- /lib/archivist/archive.ex: -------------------------------------------------------------------------------- 1 | defmodule Archivist.Archive do 2 | 3 | @moduledoc """ 4 | The heart of Archivist is the `Archive` module, which acts as a repository for 5 | exposing query functions for articles, slugs, topics, etc. You can create an 6 | Archive out of any Elixir module by using `Archivist.Archive` like this: 7 | 8 | ```elixir 9 | defmodule MyApp.Archive 10 | use Archivist.Archive 11 | end 12 | 13 | # this alias is just a nicety, not required 14 | alias MyApp.Archive 15 | 16 | Archive.articles() 17 | Archive.topics() # hierarchical topics 18 | Archive.topics_list() # flattened topics and sub-topics 19 | Archive.tags() 20 | Archive.slugs() 21 | Archive.authors() 22 | ``` 23 | 24 | Additionally Archvist exposes helpers for reading paths for articles and 25 | image files: 26 | 27 | ```elixir 28 | Archive.article_paths() 29 | Archive.image_paths() 30 | ``` 31 | 32 | Archivist 0.2.x versions expect you to create your article content directory at 33 | `priv/archive/articles` at the root of your elixir library, like this: 34 | 35 | `priv/archive/articles/journey_to_the_center_of_the_earth.ad` 36 | 37 | If you'd like to customize any of your archive's behavior, you can define any of 38 | the following options when it is used in the target archive directory. The values 39 | shown are the defaults: 40 | 41 | ```elixir 42 | defmodule MyApp.Archive 43 | use Archivist.Archive 44 | archive_dir: "priv/archive", 45 | content_dir: "articles", 46 | content_pattern: "**/*.ad", 47 | image_dir: "images", 48 | image_pattern: "**/*.{jpg,gif,png}", 49 | article_parser: &Arcdown.parse_file(&1), 50 | article_sorter: &(Map.get(&1, :published_at) >= Map.get(&2, :published_at)), 51 | slug_warnings: true, 52 | application: nil, 53 | valid_tags: nil, 54 | valid_topics: nil, 55 | valid_authors: nil, 56 | end 57 | ``` 58 | 59 | Archivist will read any files with the `.ad` extension in your content directory 60 | or in any of its subdirectories, and parse the content of those files with the 61 | parser you've selected (Arcdown by default) 62 | 63 | If you'd like to store your archive somewhere besides `priv/archive` you can 64 | assign a custom path to your archive like this: 65 | 66 | ```elixir 67 | defmodule MyApp.Archive 68 | use Archivist.Archive, archive_dir: "assets/archive", 69 | end 70 | ``` 71 | """ 72 | 73 | @doc false 74 | defmacro __using__(options) do 75 | quote bind_quoted: [options: options], unquote: true do 76 | @defaults [ 77 | archive_dir: "priv/archive", 78 | content_dir: "articles", 79 | content_pattern: "**/*.ad", 80 | image_dir: "images", 81 | image_pattern: "**/*.{jpg,gif,png}", 82 | article_sorter: &(Map.get(&1, :published_at) >= Map.get(&2, :published_at)), 83 | article_parser: &Arcdown.parse_file(&1), 84 | content_parser: &Earmark.as_html!(&1), 85 | content_parser: nil, 86 | application: nil, 87 | slug_warnings: true, 88 | valid_tags: nil, 89 | valid_topics: nil, 90 | valid_authors: nil 91 | ] 92 | 93 | @settings Keyword.merge(@defaults, options) 94 | 95 | @before_compile unquote(__MODULE__) 96 | end 97 | end 98 | 99 | alias Archivist.ArticleParser, as: Parser 100 | alias Archivist.Article 101 | 102 | @doc false 103 | defmacro __before_compile__(env) do 104 | settings = Module.get_attribute(env.module, :settings) 105 | 106 | application = settings[:application] 107 | archive_dir = settings[:archive_dir] 108 | content_dir = settings[:content_dir] 109 | content_pattern = settings[:content_pattern] 110 | image_dir = settings[:image_dir] 111 | image_pattern = settings[:image_pattern] 112 | article_sorter = settings[:article_sorter] 113 | article_parser = settings[:article_parser] 114 | content_parser = settings[:content_parser] 115 | slug_warnings = settings[:slug_warnings] 116 | valid_topics = settings[:valid_topics] 117 | valid_tags = settings[:valid_tags] 118 | valid_authors = settings[:valid_authors] 119 | 120 | article_paths = Parser.build_paths(archive_dir, content_dir, content_pattern, application) 121 | image_paths = Parser.build_paths(archive_dir, image_dir, image_pattern, application) 122 | 123 | articles = Stream.map(article_paths, article_parser) 124 | |> Parser.filter_valid 125 | |> Parser.convert_structs(Article) 126 | |> Parser.parse_content(content_parser) 127 | 128 | topics_list = Parser.parse_topics_list(articles, valid_topics) 129 | topics = Parser.parse_topics(articles) 130 | tags = Parser.parse_tags(articles, valid_tags) 131 | authors = Parser.parse_authors(articles, valid_authors) 132 | slugs = Parser.parse_slugs(articles, slug_warnings) 133 | 134 | # quote individual article paths as external resources so that they can be 135 | # tracked later for autoloading, etc. 136 | external_resources = article_paths 137 | |> Enum.map("e(do: @external_resource unquote(&1))) 138 | 139 | quote do 140 | unquote(external_resources) 141 | 142 | def articles do 143 | unquote articles 144 | |> Enum.sort(article_sorter) 145 | |> Macro.escape 146 | end 147 | 148 | def article_paths do 149 | unquote article_paths 150 | end 151 | 152 | def image_paths do 153 | unquote image_paths 154 | end 155 | 156 | def topics do 157 | unquote Macro.escape(topics) 158 | end 159 | 160 | def topics_list do 161 | unquote topics_list 162 | end 163 | 164 | def tags do 165 | unquote tags 166 | end 167 | 168 | def authors do 169 | unquote authors 170 | end 171 | 172 | def slugs do 173 | unquote slugs 174 | end 175 | end 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/archivist/article.ex: -------------------------------------------------------------------------------- 1 | defmodule Archivist.Article do 2 | @moduledoc """ 3 | The core datatype for Archivist. Articles are broken into header and 4 | body/content parts then compiled into the %Archivist.Article{} struct. 5 | """ 6 | 7 | @type t :: %__MODULE__{ 8 | title: String.t(), 9 | author: String.t(), 10 | email: String.t(), 11 | summary: String.t(), 12 | content: String.t(), 13 | parsed_content: String.t(), 14 | topics: [String.t()], 15 | tags: [atom()], 16 | slug: String.t(), 17 | created_at: DateTime.t, 18 | published_at: DateTime.t 19 | } 20 | 21 | defstruct [ 22 | :title, 23 | :author, 24 | :email, 25 | :summary, 26 | :content, 27 | :parsed_content, 28 | :topics, 29 | :tags, 30 | :slug, 31 | :created_at, 32 | :published_at 33 | ] 34 | end 35 | -------------------------------------------------------------------------------- /lib/archivist/enum_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Archivist.EnumUtils do 2 | def split_uniq(enumerable) do 3 | split_uniq_by(enumerable, fn x -> x end) 4 | end 5 | 6 | def split_uniq_by(enumerable, fun) when is_list(enumerable) do 7 | split_uniq_list(enumerable, %{}, fun) 8 | end 9 | 10 | defp split_uniq_list([head | tail], set, fun) do 11 | value = fun.(head) 12 | 13 | case set do 14 | %{^value => true} -> 15 | {uniq, dupl} = split_uniq_list(tail, set, fun) 16 | {uniq, [head | dupl]} 17 | 18 | %{} -> 19 | {uniq, dupl} = split_uniq_list(tail, Map.put(set, value, true), fun) 20 | {[head | uniq], dupl} 21 | end 22 | end 23 | 24 | defp split_uniq_list([], _set, _fun) do 25 | {[], []} 26 | end 27 | end 28 | 29 | -------------------------------------------------------------------------------- /lib/archivist/map_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule Archivist.MapUtils do 2 | def deep_merge(left, right) do 3 | Map.merge(left, right, &deep_resolve/3) 4 | end 5 | 6 | # Key exists in both maps, and both values are maps as well. 7 | # These can be merged recursively. 8 | defp deep_resolve(_key, left = %{}, right = %{}) do 9 | deep_merge(left, right) 10 | end 11 | 12 | # Key exists in both maps, but at least one of the values is 13 | # NOT a map. We fall back to standard merge behavior, preferring 14 | # the value on the right. 15 | defp deep_resolve(_key, _left, right) do 16 | right 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/archivist/parsers/article_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Archivist.ArticleParser do 2 | 3 | alias Archivist.MapUtils 4 | alias Archivist.EnumUtils 5 | 6 | # if no application is given, then we just want to expand the paths relative 7 | # to the 8 | def build_paths(archive_dir, subdir, pattern, app) when is_nil(app) do 9 | Path.join([archive_dir, subdir, pattern]) 10 | |> Path.relative_to_cwd 11 | |> Path.wildcard 12 | end 13 | 14 | def build_paths(archive_dir, subdir, pattern, app) when is_atom(app) do 15 | Path.join([:code.priv_dir(app), archive_dir, subdir, pattern]) 16 | |> Path.wildcard 17 | end 18 | 19 | def filter_valid(parsed_articles) do 20 | Stream.map(parsed_articles, fn tuple -> 21 | case tuple do 22 | {:ok, article} -> article 23 | _ -> nil 24 | end 25 | end) 26 | |> Stream.reject(&is_nil/1) 27 | end 28 | 29 | def convert_structs(parsed_articles, struct_type) do 30 | parsed_articles 31 | |> Stream.map(&Map.from_struct(&1)) 32 | |> Stream.map(&struct(struct_type, &1)) 33 | end 34 | 35 | def parse_content(articles_stream, parser) do 36 | case parser do 37 | nil -> articles_stream 38 | _ -> 39 | Stream.map(articles_stream, fn article -> 40 | content = Map.get(article, :content) 41 | parsed_content = parser.(content) 42 | Map.put(article, :parsed_content, parsed_content) 43 | end) 44 | end 45 | end 46 | 47 | def parse_attrs(attr, articles) do 48 | articles 49 | |> Stream.flat_map(&Map.get(&1, attr)) 50 | |> sanitize_attrs 51 | end 52 | 53 | def parse_attr(attr, articles) do 54 | articles 55 | |> Stream.map(&Map.get(&1, attr)) 56 | |> sanitize_attrs 57 | end 58 | 59 | defp sanitize_attrs(parsed_vals) do 60 | parsed_vals 61 | |> Stream.reject(&is_nil/1) 62 | |> Stream.uniq 63 | |> Enum.sort 64 | end 65 | 66 | def parse_topics_list(articles, valid_topics) do 67 | parse_attrs(:topics, articles) 68 | |> warn_invalid(valid_topics, :topics) 69 | end 70 | 71 | def parse_tags(articles, valid_tags) do 72 | parse_attrs(:tags, articles) 73 | |> warn_invalid(valid_tags, :tags) 74 | end 75 | 76 | def parse_authors(articles, valid_authors) do 77 | parse_attr(:author, articles) 78 | |> warn_invalid(valid_authors, :authors) 79 | end 80 | 81 | def parse_slugs(articles, slug_warnings) do 82 | articles 83 | |> Stream.map(&Map.get(&1, :slug)) 84 | |> Stream.reject(&is_nil/1) 85 | |> Enum.sort 86 | |> warn_duplicate(slug_warnings, :slugs) 87 | end 88 | 89 | defp warn_invalid(parsed_items, valid_items, attr_name) do 90 | if valid_items do 91 | invalid_items = Enum.filter(parsed_items, fn item -> 92 | !Enum.member?(valid_items, item) 93 | end) 94 | 95 | if Enum.count(invalid_items) > 0 do 96 | joined_items = Enum.join(invalid_items, ", ") 97 | "Archivist Archive contains invalid #{attr_name}: #{joined_items}" 98 | |> IO.warn(Macro.Env.stacktrace(__ENV__)) 99 | end 100 | end 101 | 102 | parsed_items 103 | end 104 | 105 | defp warn_duplicate(items, warnings, attr_name) do 106 | if warnings do 107 | 108 | {_uniqs, duplicates} = EnumUtils.split_uniq(items) 109 | 110 | if Enum.count(duplicates) > 0 do 111 | joined_dups = Enum.join(duplicates, ", ") 112 | "Archivist Archive contains duplicate #{attr_name}: #{joined_dups}" 113 | |> IO.warn(Macro.Env.stacktrace(__ENV__)) 114 | end 115 | end 116 | 117 | items 118 | end 119 | 120 | def parse_topics(articles) do 121 | articles 122 | |> Stream.map(&Map.get(&1, :topics)) 123 | |> Stream.map(&mapify_topics(&1)) 124 | |> Enum.reduce(%{}, &MapUtils.deep_merge(&2, &1)) 125 | end 126 | 127 | defp mapify_topics(nested_topics) do 128 | nested_topics 129 | |> Enum.reverse 130 | |> Enum.reduce(%{}, &Map.put(%{}, &1, &2)) 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Archivist.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.1" 5 | @description "Plain-text, version-controlled blogging in Arcdown and Markdown." 6 | 7 | def project do 8 | [ 9 | app: :archivist, 10 | name: "Archivist", 11 | version: @version, 12 | elixir: "~> 1.9", 13 | start_permanent: Mix.env() == :prod, 14 | build_embedded: Mix.env() == :prod, 15 | elixirc_paths: elixirc_paths(Mix.env), 16 | deps: deps(), 17 | description: @description, 18 | package: package(), 19 | source_url: "https://github.com/functionhaus/archivist", 20 | homepage_url: "https://functionhaus.com", 21 | docs: [ 22 | logo: "assets/functionhaus_logo.png", 23 | extras: ["README.md"], 24 | main: "readme", 25 | source_ref: "v#{@version}", 26 | source_url: "https://github.com/functionhaus/archivist" 27 | ] 28 | ] 29 | end 30 | 31 | def application do 32 | [ 33 | extra_applications: [:logger] 34 | ] 35 | end 36 | 37 | # Specifies which paths to compile per environment. 38 | defp elixirc_paths(:test), do: ["lib", "test/support"] 39 | defp elixirc_paths(_), do: ["lib"] 40 | 41 | defp deps do 42 | [ 43 | {:arcdown, "~> 0.1"}, 44 | {:earmark, "~> 1.4"}, 45 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} 46 | ] 47 | end 48 | 49 | defp package do 50 | [ 51 | files: ["lib", "mix.exs", "README.md", "LICENSE.txt"], 52 | maintainers: ["FunctionHaus LLC, Mike Zazaian"], 53 | licenses: ["Apache 2"], 54 | links: %{"GitHub" => "https://github.com/functionhaus/archivist", 55 | "Docs" => "https://hexdocs.pm/archivist/"} 56 | ] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "arcdown": {:hex, :arcdown, "0.1.2", "bb9c9b1661c5e6095cf69946dfbb57601913cf7ebe6245795bb01c977aa8da1d", [:mix], [], "hexpm"}, 3 | "earmark": {:hex, :earmark, "1.4.0", "397e750b879df18198afc66505ca87ecf6a96645545585899f6185178433cc09", [:mix], [], "hexpm"}, 4 | "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"}, 8 | "mecks_unit": {:hex, :mecks_unit, "0.1.8", "59110552978c1e54a328dbf3ac77e559d39b7f0d1d6b3acb72b9185ac5526434", [:mix], [{:meck, "~> 0.8.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "mock": {:hex, :mock, "0.3.3", "42a433794b1291a9cf1525c6d26b38e039e0d3a360732b5e467bfc77ef26c914", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "mockery": {:hex, :mockery, "2.3.0", "f1af3976916e7402427116f491e3038a251857de8f0836952c2fa24ad6de4317", [:mix], [], "hexpm"}, 11 | "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, 13 | } 14 | -------------------------------------------------------------------------------- /priv/archive/articles/Fiction/Sci-Fi/Classic/journey_to_the_center_of_the_earth.md.ad: -------------------------------------------------------------------------------- 1 | Journey to the Center of the Earth 2 | by Jules Verne 3 | 4 | Filed under: Fiction > Sci-Fi > Classic 5 | 6 | Created @ 8:24pm on 9/10/1863 7 | Published @ 11:30am on 9/16/1864 8 | 9 | * Sci-Fi 10 | * Adventure 11 | * Literature 12 | 13 | Summary: 14 | A classic sci-fi novel about an expedition to the center of the Earth 15 | 16 | --- 17 | 18 | Journey to the Center of the Earth (French: Voyage au centre de la Terre, also 19 | translated under the titles A Journey to the Centre of the Earth and A Journey 20 | to the Interior of the Earth) is an 1864 science fiction novel by Jules Verne. 21 | The story involves German professor Otto Lidenbrock who believes there are 22 | volcanic tubes going toward the centre of the Earth. He, his nephew Axel, and 23 | their guide Hans descend into the Icelandic volcano Snæfellsjökull, encountering 24 | many adventures, including prehistoric animals and natural hazards, before 25 | eventually coming to the surface again in southern Italy, at the Stromboli 26 | volcano. 27 | -------------------------------------------------------------------------------- /priv/archive/articles/Films/Action/Crime/heat.md.ad: -------------------------------------------------------------------------------- 1 | Heat 2 | by Michael Mann 3 | 4 | Filed under: Films > Action > Crime 5 | 6 | Created @ 10:24pm on 2/20/1995 7 | Published @ 4:30am on 4/2/1996 8 | 9 | * modern_classic 10 | * action 11 | * crime 12 | 13 | Summary: 14 | A modern classic about the fine line between good and evil 15 | 16 | --- 17 | 18 | Heat is a 1995 American neo-noir crime film written, produced, and directed by 19 | Michael Mann, starring Al Pacino, Robert De Niro, and Val Kilmer. De Niro plays 20 | Neil McCauley, a seasoned professional at robberies, and Pacino plays Lt. 21 | Vincent Hanna, an LAPD robbery-homicide detective tracking down Neil's crew 22 | after a botched heist leaves three security guards dead. The story is based on 23 | the former Chicago police officer Chuck Adamson's pursuit during the 1960s of a 24 | criminal named McCauley, after whom De Niro's character is named. Heat is a 25 | remake by Mann of an unproduced television series he had worked on, the pilot of 26 | which was released as the TV movie L.A. Takedown in 1991. 27 | -------------------------------------------------------------------------------- /priv/archive/articles/Films/Sci-Fi/Classic/the_day_the_earth_stood_still.ad: -------------------------------------------------------------------------------- 1 | The Day the Earth Stood Still 2 | by Julian Blaustein 3 | 4 | Filed under: Films > Sci-Fi > Classic 5 | 6 | Created @ 10:24pm on 1/20/2019 7 | Published @ 4:30am on 4/2/2019 8 | 9 | * Sci-Fi 10 | * Horror 11 | * Thrillers 12 | * Aliens 13 | 14 | Summary: 15 | A sci-fi classic about a flying saucer landing in Washington, D.C. 16 | 17 | --- 18 | 19 | The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the 20 | World) is a 1951 American black-and-white science fiction film from 20th Century 21 | Fox, produced by Julian Blaustein and directed by Robert Wise. 22 | -------------------------------------------------------------------------------- /priv/archive/images/2001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/priv/archive/images/2001.jpg -------------------------------------------------------------------------------- /priv/archive/images/big_lebowski.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/priv/archive/images/big_lebowski.png -------------------------------------------------------------------------------- /priv/archive/images/chameleon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/priv/archive/images/chameleon.jpg -------------------------------------------------------------------------------- /priv/archive/images/michael.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/priv/archive/images/michael.gif -------------------------------------------------------------------------------- /test/archivist/duplicate_slugs_test.exs: -------------------------------------------------------------------------------- 1 | defmodule DupSlugsArchiveTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "warnings should be thrown on duplicate slugs" do 5 | assert DupSlugsArchive.slugs() == [ 6 | "the-day-the-earth-stood-still", 7 | "the-day-the-earth-stood-still", 8 | "the-day-the-earth-stood-still" 9 | ] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/archivist/local_archive_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LocalArchiveTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "generates a list of article paths" do 5 | assert LocalArchive.article_paths() == 6 | [ 7 | "test/support/archives/local/articles/Fiction/Sci-Fi/Classic/journey_to_the_center_of_the_earth.md.ad", 8 | "test/support/archives/local/articles/Films/Action/Crime/heat.md.ad", 9 | "test/support/archives/local/articles/Films/Sci-Fi/Classic/the_day_the_earth_stood_still.ad" 10 | ] 11 | end 12 | 13 | test "parses a list of articles" do 14 | assert LocalArchive.articles() == [ 15 | %Archivist.Article{ 16 | author: "Jules Verne", 17 | content: "Journey to the Center of the Earth (French: Voyage au centre de la Terre, also\ntranslated under the titles A Journey to the Centre of the Earth and A Journey\nto the Interior of the Earth) is an 1864 science fiction novel by Jules Verne.\nThe story involves German professor Otto Lidenbrock who believes there are\nvolcanic tubes going toward the centre of the Earth. He, his nephew Axel, and\ntheir guide Hans descend into the Icelandic volcano Snæfellsjökull, encountering\nmany adventures, including prehistoric animals and natural hazards, before\neventually coming to the surface again in southern Italy, at the Stromboli\nvolcano.\n", 18 | parsed_content: "

Journey to the Center of the Earth (French: Voyage au centre de la Terre, also\ntranslated under the titles A Journey to the Centre of the Earth and A Journey\nto the Interior of the Earth) is an 1864 science fiction novel by Jules Verne.\nThe story involves German professor Otto Lidenbrock who believes there are\nvolcanic tubes going toward the centre of the Earth. He, his nephew Axel, and\ntheir guide Hans descend into the Icelandic volcano Snæfellsjökull, encountering\nmany adventures, including prehistoric animals and natural hazards, before\neventually coming to the surface again in southern Italy, at the Stromboli\nvolcano.

\n", 19 | created_at: ~U[1863-09-10 20:24:00Z], email: "jules@verne.com", 20 | published_at: ~U[1864-09-16 11:30:00Z], slug: "journey-to-the-center-of-the-earth", 21 | summary: "A classic sci-fi novel about an expedition to the center of the Earth", 22 | tags: [:sci_fi, :adventure, :literature], title: "Journey to the Center of the Earth", 23 | topics: ["Fiction", "Sci-Fi","Classic"] 24 | }, 25 | 26 | %Archivist.Article{ 27 | author: "Julian Blaustein", 28 | content: "The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the\nWorld) is a 1951 American black-and-white science fiction film from 20th Century\nFox, produced by Julian Blaustein and directed by Robert Wise.\n", 29 | parsed_content: "

The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the\nWorld) is a 1951 American black-and-white science fiction film from 20th Century\nFox, produced by Julian Blaustein and directed by Robert Wise.

\n", 30 | created_at: ~U[2019-01-20 22:24:00Z], email: "julian@blaustein.com", 31 | published_at: ~U[2019-04-02 04:30:00Z], slug: "the-day-the-earth-stood-still", 32 | summary: "A sci-fi classic about a flying saucer landing in Washington, D.C.", 33 | tags: [:sci_fi, :horror, :thrillers, :aliens], title: "The Day the Earth Stood Still", 34 | topics: ["Films", "Sci-Fi", "Classic"] 35 | }, 36 | 37 | %Archivist.Article{ 38 | author: "Michael Mann", 39 | content: "Heat is a 1995 American neo-noir crime film written, produced, and directed by\nMichael Mann, starring Al Pacino, Robert De Niro, and Val Kilmer. De Niro plays\nNeil McCauley, a seasoned professional at robberies, and Pacino plays Lt.\nVincent Hanna, an LAPD robbery-homicide detective tracking down Neil's crew\nafter a botched heist leaves three security guards dead. The story is based on\nthe former Chicago police officer Chuck Adamson's pursuit during the 1960s of a\ncriminal named McCauley, after whom De Niro's character is named. Heat is a\nremake by Mann of an unproduced television series he had worked on, the pilot of\nwhich was released as the TV movie L.A. Takedown in 1991.\n", 40 | parsed_content: "

Heat is a 1995 American neo-noir crime film written, produced, and directed by\nMichael Mann, starring Al Pacino, Robert De Niro, and Val Kilmer. De Niro plays\nNeil McCauley, a seasoned professional at robberies, and Pacino plays Lt.\nVincent Hanna, an LAPD robbery-homicide detective tracking down Neil’s crew\nafter a botched heist leaves three security guards dead. The story is based on\nthe former Chicago police officer Chuck Adamson’s pursuit during the 1960s of a\ncriminal named McCauley, after whom De Niro’s character is named. Heat is a\nremake by Mann of an unproduced television series he had worked on, the pilot of\nwhich was released as the TV movie L.A. Takedown in 1991.

\n", 41 | created_at: ~U[1995-02-20 22:24:00Z], email: "michael@mann.org", 42 | published_at: ~U[1996-04-02 04:30:00Z], slug: "heat", 43 | summary: "A modern classic about the fine line between good and evil", 44 | tags: [:modern_classic, :action, :crime], 45 | title: "Heat", 46 | topics: ["Films", "Action", "Crime"], 47 | } 48 | ] 49 | end 50 | 51 | test "compile list of image paths" do 52 | assert LocalArchive.image_paths() == [ 53 | "test/support/archives/local/images/2001.jpg", 54 | "test/support/archives/local/images/big_lebowski.png", 55 | "test/support/archives/local/images/chameleon.jpg", 56 | "test/support/archives/local/images/michael.gif" 57 | ] 58 | end 59 | 60 | test "compile sorted list of unique authors" do 61 | assert LocalArchive.authors() == [ 62 | "Jules Verne", 63 | "Julian Blaustein", 64 | "Michael Mann" 65 | ] 66 | end 67 | 68 | test "compile hierarchical list of topics" do 69 | assert LocalArchive.topics() == %{ 70 | "Fiction" => %{ 71 | "Sci-Fi" => %{ 72 | "Classic" => %{} 73 | }, 74 | }, 75 | "Films" => %{ 76 | "Sci-Fi" => %{ 77 | "Classic" => %{} 78 | }, 79 | "Action" => %{ 80 | "Crime" => %{} 81 | } 82 | } 83 | } 84 | end 85 | 86 | test "compile a flattened list of all topics and sub-topics" do 87 | assert LocalArchive.topics_list() == [ 88 | "Action", 89 | "Classic", 90 | "Crime", 91 | "Fiction", 92 | "Films", 93 | "Sci-Fi" 94 | ] 95 | end 96 | 97 | test "compiled sorted list of unique tags" do 98 | assert LocalArchive.tags() == [ 99 | :action, 100 | :adventure, 101 | :aliens, 102 | :crime, 103 | :horror, 104 | :literature, 105 | :modern_classic, 106 | :sci_fi, 107 | :thrillers 108 | ] 109 | end 110 | 111 | test "compiled sorted list of unique slugs" do 112 | assert LocalArchive.slugs() == [ 113 | "heat", 114 | "journey-to-the-center-of-the-earth", 115 | "the-day-the-earth-stood-still" 116 | ] 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/archivist/remote_archive_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RemoteArchiveTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "remote archive should use priv paths" do 5 | priv_dir = :code.priv_dir(:archivist) 6 | 7 | expected_paths = [ 8 | "archive/images/2001.jpg", 9 | "archive/images/big_lebowski.png", 10 | "archive/images/chameleon.jpg", 11 | "archive/images/michael.gif" 12 | ] |> Enum.map(&Path.join(priv_dir, &1)) 13 | 14 | assert RemoteArchive.image_paths() == expected_paths 15 | end 16 | 17 | test "generates a list of remote article paths" do 18 | priv_dir = :code.priv_dir(:archivist) 19 | 20 | expected_paths = [ 21 | "archive/articles/Fiction/Sci-Fi/Classic/journey_to_the_center_of_the_earth.md.ad", 22 | "archive/articles/Films/Action/Crime/heat.md.ad", 23 | "archive/articles/Films/Sci-Fi/Classic/the_day_the_earth_stood_still.ad" 24 | ] |> Enum.map(&Path.join(priv_dir, &1)) 25 | 26 | assert RemoteArchive.article_paths() == expected_paths 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/archivist/unparsed_content_test.exs: -------------------------------------------------------------------------------- 1 | defmodule UnparsedContentTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "parses a list of articles" do 5 | assert UnparsedContentArchive.articles() == [ 6 | %Archivist.Article{ 7 | author: "Jules Verne", 8 | content: "Journey to the Center of the Earth (French: Voyage au centre de la Terre, also\ntranslated under the titles A Journey to the Centre of the Earth and A Journey\nto the Interior of the Earth) is an 1864 science fiction novel by Jules Verne.\nThe story involves German professor Otto Lidenbrock who believes there are\nvolcanic tubes going toward the centre of the Earth. He, his nephew Axel, and\ntheir guide Hans descend into the Icelandic volcano Snæfellsjökull, encountering\nmany adventures, including prehistoric animals and natural hazards, before\neventually coming to the surface again in southern Italy, at the Stromboli\nvolcano.\n", 9 | created_at: ~U[1863-09-10 20:24:00Z], email: "jules@verne.com", 10 | published_at: ~U[1864-09-16 11:30:00Z], slug: "journey-to-the-center-of-the-earth", 11 | summary: "A classic sci-fi novel about an expedition to the center of the Earth", 12 | tags: [:sci_fi, :adventure, :literature], title: "Journey to the Center of the Earth", 13 | topics: ["Fiction", "Sci-Fi","Classic"] 14 | }, 15 | 16 | %Archivist.Article{ 17 | author: "Julian Blaustein", 18 | content: "The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the\nWorld) is a 1951 American black-and-white science fiction film from 20th Century\nFox, produced by Julian Blaustein and directed by Robert Wise.\n", 19 | created_at: ~U[2019-01-20 22:24:00Z], email: "julian@blaustein.com", 20 | published_at: ~U[2019-04-02 04:30:00Z], slug: "the-day-the-earth-stood-still", 21 | summary: "A sci-fi classic about a flying saucer landing in Washington, D.C.", 22 | tags: [:sci_fi, :horror, :thrillers, :aliens], title: "The Day the Earth Stood Still", 23 | topics: ["Films", "Sci-Fi", "Classic"] 24 | }, 25 | 26 | %Archivist.Article{ 27 | author: "Michael Mann", 28 | content: "Heat is a 1995 American neo-noir crime film written, produced, and directed by\nMichael Mann, starring Al Pacino, Robert De Niro, and Val Kilmer. De Niro plays\nNeil McCauley, a seasoned professional at robberies, and Pacino plays Lt.\nVincent Hanna, an LAPD robbery-homicide detective tracking down Neil's crew\nafter a botched heist leaves three security guards dead. The story is based on\nthe former Chicago police officer Chuck Adamson's pursuit during the 1960s of a\ncriminal named McCauley, after whom De Niro's character is named. Heat is a\nremake by Mann of an unproduced television series he had worked on, the pilot of\nwhich was released as the TV movie L.A. Takedown in 1991.\n", 29 | created_at: ~U[1995-02-20 22:24:00Z], email: "michael@mann.org", 30 | published_at: ~U[1996-04-02 04:30:00Z], slug: "heat", 31 | summary: "A modern classic about the fine line between good and evil", 32 | tags: [:modern_classic, :action, :crime], 33 | title: "Heat", 34 | topics: ["Films", "Action", "Crime"], 35 | } 36 | ] 37 | end 38 | 39 | end 40 | -------------------------------------------------------------------------------- /test/archivist_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ArchivistTest do 2 | use ExUnit.Case, async: false 3 | 4 | end 5 | -------------------------------------------------------------------------------- /test/support/archives/duplicate_slugs/articles/the_day_the_earth_stood_still.ad: -------------------------------------------------------------------------------- 1 | The Day the Earth Stood Still 2 | by Julian Blaustein 3 | 4 | Filed under: Films > Sci-Fi > Classic 5 | 6 | Created @ 10:24pm on 1/20/2019 7 | Published @ 4:30am on 4/2/2019 8 | 9 | * Sci-Fi 10 | * Horror 11 | * Thrillers 12 | * Aliens 13 | 14 | Summary: 15 | A sci-fi classic about a flying saucer landing in Washington, D.C. 16 | 17 | --- 18 | 19 | The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the 20 | World) is a 1951 American black-and-white science fiction film from 20th Century 21 | Fox, produced by Julian Blaustein and directed by Robert Wise. 22 | -------------------------------------------------------------------------------- /test/support/archives/duplicate_slugs/articles/the_day_the_earth_stood_still2.ad: -------------------------------------------------------------------------------- 1 | The Day the Earth Stood Still 2 | by Julian Blaustein 3 | 4 | Filed under: Films > Sci-Fi > Classic 5 | 6 | Created @ 10:24pm on 1/20/2019 7 | Published @ 4:30am on 4/2/2019 8 | 9 | * Sci-Fi 10 | * Horror 11 | * Thrillers 12 | * Aliens 13 | 14 | Summary: 15 | A sci-fi classic about a flying saucer landing in Washington, D.C. 16 | 17 | --- 18 | 19 | The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the 20 | World) is a 1951 American black-and-white science fiction film from 20th Century 21 | Fox, produced by Julian Blaustein and directed by Robert Wise. 22 | -------------------------------------------------------------------------------- /test/support/archives/duplicate_slugs/articles/the_day_the_earth_stood_still3.ad: -------------------------------------------------------------------------------- 1 | The Day the Earth Stood Still 2 | by Julian Blaustein 3 | 4 | Filed under: Films > Sci-Fi > Classic 5 | 6 | Created @ 10:24pm on 1/20/2019 7 | Published @ 4:30am on 4/2/2019 8 | 9 | * Sci-Fi 10 | * Horror 11 | * Thrillers 12 | * Aliens 13 | 14 | Summary: 15 | A sci-fi classic about a flying saucer landing in Washington, D.C. 16 | 17 | --- 18 | 19 | The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the 20 | World) is a 1951 American black-and-white science fiction film from 20th Century 21 | Fox, produced by Julian Blaustein and directed by Robert Wise. 22 | -------------------------------------------------------------------------------- /test/support/archives/local/articles/Fiction/Sci-Fi/Classic/journey_to_the_center_of_the_earth.md.ad: -------------------------------------------------------------------------------- 1 | Journey to the Center of the Earth 2 | by Jules Verne 3 | 4 | Filed under: Fiction > Sci-Fi > Classic 5 | 6 | Created @ 8:24pm on 9/10/1863 7 | Published @ 11:30am on 9/16/1864 8 | 9 | * Sci-Fi 10 | * Adventure 11 | * Literature 12 | 13 | Summary: 14 | A classic sci-fi novel about an expedition to the center of the Earth 15 | 16 | --- 17 | 18 | Journey to the Center of the Earth (French: Voyage au centre de la Terre, also 19 | translated under the titles A Journey to the Centre of the Earth and A Journey 20 | to the Interior of the Earth) is an 1864 science fiction novel by Jules Verne. 21 | The story involves German professor Otto Lidenbrock who believes there are 22 | volcanic tubes going toward the centre of the Earth. He, his nephew Axel, and 23 | their guide Hans descend into the Icelandic volcano Snæfellsjökull, encountering 24 | many adventures, including prehistoric animals and natural hazards, before 25 | eventually coming to the surface again in southern Italy, at the Stromboli 26 | volcano. 27 | -------------------------------------------------------------------------------- /test/support/archives/local/articles/Films/Action/Crime/heat.md.ad: -------------------------------------------------------------------------------- 1 | Heat 2 | by Michael Mann 3 | 4 | Filed under: Films > Action > Crime 5 | 6 | Created @ 10:24pm on 2/20/1995 7 | Published @ 4:30am on 4/2/1996 8 | 9 | * modern_classic 10 | * action 11 | * crime 12 | 13 | Summary: 14 | A modern classic about the fine line between good and evil 15 | 16 | --- 17 | 18 | Heat is a 1995 American neo-noir crime film written, produced, and directed by 19 | Michael Mann, starring Al Pacino, Robert De Niro, and Val Kilmer. De Niro plays 20 | Neil McCauley, a seasoned professional at robberies, and Pacino plays Lt. 21 | Vincent Hanna, an LAPD robbery-homicide detective tracking down Neil's crew 22 | after a botched heist leaves three security guards dead. The story is based on 23 | the former Chicago police officer Chuck Adamson's pursuit during the 1960s of a 24 | criminal named McCauley, after whom De Niro's character is named. Heat is a 25 | remake by Mann of an unproduced television series he had worked on, the pilot of 26 | which was released as the TV movie L.A. Takedown in 1991. 27 | -------------------------------------------------------------------------------- /test/support/archives/local/articles/Films/Sci-Fi/Classic/the_day_the_earth_stood_still.ad: -------------------------------------------------------------------------------- 1 | The Day the Earth Stood Still 2 | by Julian Blaustein 3 | 4 | Filed under: Films > Sci-Fi > Classic 5 | 6 | Created @ 10:24pm on 1/20/2019 7 | Published @ 4:30am on 4/2/2019 8 | 9 | * Sci-Fi 10 | * Horror 11 | * Thrillers 12 | * Aliens 13 | 14 | Summary: 15 | A sci-fi classic about a flying saucer landing in Washington, D.C. 16 | 17 | --- 18 | 19 | The Day the Earth Stood Still (a.k.a. Farewell to the Master and Journey to the 20 | World) is a 1951 American black-and-white science fiction film from 20th Century 21 | Fox, produced by Julian Blaustein and directed by Robert Wise. 22 | -------------------------------------------------------------------------------- /test/support/archives/local/images/2001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/test/support/archives/local/images/2001.jpg -------------------------------------------------------------------------------- /test/support/archives/local/images/big_lebowski.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/test/support/archives/local/images/big_lebowski.png -------------------------------------------------------------------------------- /test/support/archives/local/images/chameleon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/test/support/archives/local/images/chameleon.jpg -------------------------------------------------------------------------------- /test/support/archives/local/images/michael.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/functionhaus/archivist/4601290059539c5f40e1c995ae1340e74f30c96f/test/support/archives/local/images/michael.gif -------------------------------------------------------------------------------- /test/support/dup_slugs_archive.ex: -------------------------------------------------------------------------------- 1 | defmodule DupSlugsArchive do 2 | use Archivist.Archive, 3 | archive_dir: "test/support/archives/duplicate_slugs" 4 | end 5 | -------------------------------------------------------------------------------- /test/support/local_archive.ex: -------------------------------------------------------------------------------- 1 | defmodule LocalArchive do 2 | use Archivist.Archive, 3 | archive_dir: "test/support/archives/local" 4 | end 5 | -------------------------------------------------------------------------------- /test/support/remote_archive.ex: -------------------------------------------------------------------------------- 1 | defmodule RemoteArchive do 2 | # it is unlikely that you would call :archivist in a real-world example, 3 | # but it's being used here to test the resolution relative to the current 4 | # app's priv directory 5 | 6 | use Archivist.Archive, 7 | archive_dir: "archive", 8 | application: :archivist 9 | end 10 | -------------------------------------------------------------------------------- /test/support/unparsed_content_archive.ex: -------------------------------------------------------------------------------- 1 | defmodule UnparsedContentArchive do 2 | use Archivist.Archive, 3 | archive_dir: "test/support/archives/local", 4 | content_parser: nil 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------