diff --git a/docs/guide/layer.md b/docs/guide/layer.md index a84fd4c50..c76cf69f9 100644 --- a/docs/guide/layer.md +++ b/docs/guide/layer.md @@ -1,6 +1,14 @@ # Layer system -Emanote's layer system allows you to "merge" multiple notebooks and treat them as if they were a single notebook. The `-L` option in the command line accepts layers, and you can specify multiple of them with the leftmost taking the most precedence. +Emanote's layer system allows you to "merge" multiple notebook directories and treat them as if they were a single notebook directory. The `-L` option in the command line accepts layers, and you can specify multiple of them with the leftmost taking the most precedence. + +For example, + +```sh +emanote -L ./docs1:./docs2 run +``` + +Internally, Emante merges both `docs1` and `docs2` folders and treats them as a single directory. Thus, both `docs1` and `docs2` can contain the same file, and the one in `docs1` will take precedence. ## "Default" layer @@ -9,3 +17,13 @@ Emanote *implicitly* includes what is known as the "default" layer. Its contents ## Merge semantics The default merge semantic is to replace with the file on the right layer. For some file types, special merge semantic applies. For example, [[yaml-config|YAML files]] are merged by deep merge, not file-level replacement. This is what allows you to create `index.yaml` that overrides only a subset of the default configuration. + +## Mount point + +Layers can be mounted at a specific path. For example, if you want to mount `docs1` at `/D1` and `docs2` at `/D2`, you can do so with: + +```sh +emanote -L /docs1@D1;/docs2@D2 run +``` + +When two layers are mounted at distinct mount points it becomes impossible for there to be overlaps. This is useful to host sub-sites under a single site, such as in [this case](https://github.com/flake-parts/community.flake.parts). \ No newline at end of file diff --git a/emanote/CHANGELOG.md b/emanote/CHANGELOG.md index 40177ba0e..38588efe9 100644 --- a/emanote/CHANGELOG.md +++ b/emanote/CHANGELOG.md @@ -11,6 +11,7 @@ - Resolve ambiguities based on closer common ancestor ([\#498](https://github.com/srid/emanote/pull/498)) - Support for folder "index.md" notes ([\#512](https://github.com/srid/emanote/pull/512)) - Instead of "foo/qux.md", you can now create "foo/qux/index.md" + - Layers can be mounted in sub-directories, enabling composition of distinct notebooks ([\#523](https://github.com/srid/emanote/pull/523)) - **BACKWARDS INCOMPTABILE** changes - `feed.siteUrl` is now `page.siteUrl` - A new HTML template layout "default" (unifies and) replaces both "book" and "note" layout. ([\#483](https://github.com/srid/emanote/pull/483)) diff --git a/emanote/emanote.cabal b/emanote/emanote.cabal index a393ec1e2..7aa22c860 100644 --- a/emanote/emanote.cabal +++ b/emanote/emanote.cabal @@ -1,6 +1,6 @@ cabal-version: 2.4 name: emanote -version: 1.3.14.3 +version: 1.3.15.0 license: AGPL-3.0-only copyright: 2022 Sridhar Ratnakumar maintainer: srid@srid.ca diff --git a/emanote/src/Emanote/CLI.hs b/emanote/src/Emanote/CLI.hs index 9eee61c4e..e5fc03c4d 100644 --- a/emanote/src/Emanote/CLI.hs +++ b/emanote/src/Emanote/CLI.hs @@ -3,6 +3,7 @@ module Emanote.CLI ( Cli (..), + Layer (..), Cmd (..), parseCli, cliParser, @@ -17,26 +18,31 @@ import Relude import UnliftIO.Directory (getCurrentDirectory) data Cli = Cli - { layers :: NonEmpty FilePath + { layers :: NonEmpty Layer , allowBrokenLinks :: Bool , cmd :: Cmd } +data Layer = Layer + { path :: FilePath + , mountPoint :: Maybe FilePath + } + data Cmd = Cmd_Ema Ema.CLI.Cli | Cmd_Export cliParser :: FilePath -> Parser Cli cliParser cwd = do - layers <- pathList (one cwd) + layers <- layerList $ one $ Layer cwd Nothing allowBrokenLinks <- switch (long "allow-broken-links" <> help "Report but do not fail on broken links") cmd <- fmap Cmd_Ema Ema.CLI.cliParser <|> subparser (command "export" (info (pure Cmd_Export) (progDesc "Export metadata JSON"))) pure Cli {..} where - pathList defaultPath = do - option pathListReader + layerList defaultPath = do + option layerListReader $ mconcat [ long "layers" , short 'L' @@ -44,10 +50,17 @@ cliParser cwd = do , value defaultPath , help "List of (semicolon delimited) notebook folders to 'union mount', with the left-side folders being overlaid on top of the right-side ones. The default layer is implicitly included at the end of this list." ] - pathListReader :: ReadM (NonEmpty FilePath) - pathListReader = + layerListReader :: ReadM (NonEmpty Layer) + layerListReader = do + let partition s = + T.breakOn "@" s + & second (\x -> if T.null s then Nothing else Just $ T.drop 1 x) maybeReader $ \paths -> - nonEmpty $ fmap toString $ T.split (== ';') . toText $ paths + nonEmpty + $ fmap (uncurry Layer . bimap toString (fmap toString) . partition) + $ T.split (== ';') + . toText + $ paths parseCli' :: FilePath -> ParserInfo Cli parseCli' cwd = diff --git a/emanote/src/Emanote/Model/Stork.hs b/emanote/src/Emanote/Model/Stork.hs index 801ad59c4..06b7c5e6f 100644 --- a/emanote/src/Emanote/Model/Stork.hs +++ b/emanote/src/Emanote/Model/Stork.hs @@ -33,7 +33,7 @@ renderStorkIndex model = do storkFiles :: Model -> [File] storkFiles model = flip mapMaybe (Ix.toList (model ^. M.modelNotes)) $ \note -> do - baseDir <- Loc.locPath . fst <$> note ^. N.noteSource + baseDir <- fst . Loc.locPath . fst <$> note ^. N.noteSource let fp = ((baseDir ) $ R.withLmlRoute R.encodeRoute $ note ^. N.noteRoute) ft = case note ^. N.noteRoute of R.LMLRoute_Md _ -> FileType_Markdown diff --git a/emanote/src/Emanote/Source/Dynamic.hs b/emanote/src/Emanote/Source/Dynamic.hs index 187bd9ec6..53c2ffac4 100644 --- a/emanote/src/Emanote/Source/Dynamic.hs +++ b/emanote/src/Emanote/Source/Dynamic.hs @@ -55,7 +55,7 @@ emanoteSiteInput cliAct EmanoteConfig {..} = do defaultLayer <- Loc.defaultLayer <$> liftIO Paths_emanote.getDataDir instanceId <- liftIO UUID.nextRandom storkIndex <- Stork.newIndex - let layers = Loc.userLayers (CLI.layers _emanoteConfigCli) <> one defaultLayer + let layers = Loc.userLayers ((CLI.path &&& CLI.mountPoint) <$> CLI.layers _emanoteConfigCli) <> one defaultLayer initialModel = Model.emptyModel layers cliAct _emanoteConfigPandocRenderers _emanoteCompileTailwind instanceId storkIndex scriptingEngine <- getEngine Dynamic diff --git a/emanote/src/Emanote/Source/Loc.hs b/emanote/src/Emanote/Source/Loc.hs index 7e2ac11ca..2eb0677b8 100644 --- a/emanote/src/Emanote/Source/Loc.hs +++ b/emanote/src/Emanote/Source/Loc.hs @@ -12,9 +12,9 @@ module Emanote.Source.Loc ( -- * Using a `Loc` locResolve, locPath, + locMountPoint, -- * Dealing with layers of locs - LocLayers, userLayersToSearch, ) where @@ -28,24 +28,22 @@ import System.FilePath (()) The order here matters. Top = higher precedence. -} data Loc - = -- | The Int argument specifies the precedence (lower value = higher precedence) - LocUser Int FilePath + = -- | The Int argument specifies the precedence (lower value = higher precedence). The last argument is "mount point" + LocUser Int FilePath (Maybe FilePath) | -- | The default location (ie., emanote default layer) LocDefault FilePath deriving stock (Eq, Ord, Show, Generic) deriving anyclass (Aeson.ToJSON) -type LocLayers = Set Loc - {- | List of user layers, highest precedent being at first. This is useful to delay searching for content in layers. -} -userLayersToSearch :: LocLayers -> [FilePath] +userLayersToSearch :: Set Loc -> [FilePath] userLayersToSearch = mapMaybe ( \case - LocUser _ fp -> Just fp + LocUser _ fp _ -> Just fp LocDefault _ -> Nothing ) . Set.toAscList @@ -53,17 +51,22 @@ userLayersToSearch = defaultLayer :: FilePath -> Loc defaultLayer = LocDefault -userLayers :: NonEmpty FilePath -> Set Loc +userLayers :: NonEmpty (FilePath, Maybe FilePath) -> Set Loc userLayers paths = fromList $ zip [1 ..] (toList paths) - <&> uncurry LocUser + <&> (\(a, (b, c)) -> LocUser a b c) -- | Return the effective path of a file. locResolve :: (Loc, FilePath) -> FilePath -locResolve (loc, fp) = locPath loc fp +locResolve (loc, fp) = fst (locPath loc) fp -locPath :: Loc -> FilePath +locPath :: Loc -> (FilePath, Maybe FilePath) locPath = \case - LocUser _ fp -> fp - LocDefault fp -> fp + LocUser _ fp m -> (fp, m) + LocDefault fp -> (fp, Nothing) + +locMountPoint :: Loc -> Maybe FilePath +locMountPoint = \case + LocUser _ _ mountPoint -> mountPoint + LocDefault _ -> Nothing diff --git a/emanote/src/Emanote/Source/Patch.hs b/emanote/src/Emanote/Source/Patch.hs index 17a6863e9..8dcc70e5f 100644 --- a/emanote/src/Emanote/Source/Patch.hs +++ b/emanote/src/Emanote/Source/Patch.hs @@ -22,7 +22,7 @@ import Emanote.Prelude ( logD, ) import Emanote.Route qualified as R -import Emanote.Source.Loc (Loc, LocLayers, locResolve, userLayersToSearch) +import Emanote.Source.Loc (Loc, locResolve, userLayersToSearch) import Emanote.Source.Pattern (filePatterns, ignorePatterns) import Heist.Extra.TemplateState qualified as T import Optics.Operators ((%~)) @@ -36,7 +36,7 @@ import UnliftIO.Directory (doesDirectoryExist) -- | Map a filesystem change to the corresponding model change. patchModel :: (MonadIO m, MonadLogger m, MonadLoggerIO m) => - LocLayers -> + Set Loc -> (N.Note -> N.Note) -> Stork.IndexVar -> -- | Lua scripting engine @@ -59,7 +59,7 @@ patchModel layers noteF storkIndexTVar scriptingEngine fpType fp action = do -- | Map a filesystem change to the corresponding model change. patchModel' :: (MonadIO m, MonadLogger m) => - LocLayers -> + Set Loc -> (N.Note -> N.Note) -> Stork.IndexVar -> -- | Lua scripting engine diff --git a/flake.lock b/flake.lock index 4b8d838bc..4e5bfd545 100644 --- a/flake.lock +++ b/flake.lock @@ -232,11 +232,11 @@ "unionmount": { "flake": false, "locked": { - "lastModified": 1691619410, - "narHash": "sha256-V9/OcGu9cy4kV9jta12A6w5BEj8awSEVYrXPpg8YckQ=", + "lastModified": 1710078535, + "narHash": "sha256-gKBgBtuiRTD3/3EeY8aMgFzuaSEffJacBxsCB3ct1eg=", "owner": "srid", "repo": "unionmount", - "rev": "ed73b627f88c8f021f41ba4b518ba41beff9df42", + "rev": "41ae982fa118770bf4d3a3f2d48ac1ffb61c9f09", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index ff8488778..d81450ef4 100644 --- a/flake.nix +++ b/flake.nix @@ -90,6 +90,7 @@ tagtree.jailbreak = true; tailwind.broken = false; tailwind.jailbreak = true; + unionmount.check = !pkgs.stdenv.isDarwin; # garnix: Slow M1 builder emanote = { name, pkgs, self, super, ... }: { check = false; extraBuildDepends = [ pkgs.stork ]; @@ -175,8 +176,7 @@ package = config.packages.default; sites = { "docs" = { - layers = [ ./docs ]; - layersString = [ "./docs" ]; + layers = [{ path = ./docs; pathString = "./docs"; }]; allowBrokenLinks = true; # A couple, by design, in markdown.md prettyUrls = true; }; diff --git a/nix/flake-module.nix b/nix/flake-module.nix index 54a5d97ea..14c30bdc5 100644 --- a/nix/flake-module.nix +++ b/nix/flake-module.nix @@ -31,17 +31,42 @@ in description = "Emanote sites"; type = types.attrsOf (types.submodule { options = { - layers = mkOption { - type = types.listOf types.path; - description = ''List of directory paths to run Emanote on''; - }; - # HACK: I can't seem to be able to convert `path` to a - # relative local path; so this is necessary. - # - # cf. https://discourse.nixos.org/t/converting-from-types-path-to-types-str/19405?u=srid - layersString = mkOption { - type = types.listOf types.str; - description = ''Like `layers` but local (not in Nix store)''; + layers = lib.mkOption { + description = "List of layers to use for the site"; + type = types.listOf (types.submodule ({ config, ... }: { + options = { + path = mkOption { + type = types.path; + description = ''Directory path to notes''; + }; + # HACK: I can't seem to be able to convert `path` to a + # relative local path; so this is necessary. + # + # cf. https://discourse.nixos.org/t/converting-from-types-path-to-types-str/19405?u=srid + pathString = mkOption { + type = types.str; + description = ''Like `path` but local (not in Nix store)''; + default = builtins.toString config.path; + }; + mountPoint = mkOption { + type = types.nullOr types.str; + description = ''Mount point for the layer''; + default = null; + }; + outputs.layer = mkOption { + type = types.str; + description = ''Layer spec''; + readOnly = true; + default = if config.mountPoint == null then "${config.path}" else "${config.path}@${config.mountPoint}"; + }; + outputs.layerString = mkOption { + type = types.str; + description = ''Layer spec''; + readOnly = true; + default = if config.mountPoint == null then config.pathString else "${config.pathString}@${config.mountPoint}"; + }; + }; + })); }; # TODO: Consolidate all these options below with those of home-manager-module.nix port = mkOption { @@ -96,7 +121,7 @@ in runtimeInputs = [ config.emanote.package ]; text = let - layers = lib.concatStringsSep ";" cfg.layersString; + layers = lib.concatStringsSep ";" (builtins.map (x: x.outputs.layerString) cfg.layers); in '' set -xe @@ -123,7 +148,7 @@ in mkdir -p $out cp ${configFile} $out/index.yaml ''; - layers = lib.concatStringsSep ";" cfg.layers; + layers = lib.concatStringsSep ";" (builtins.map (x: x.outputs.layer) cfg.layers); in pkgs.runCommand "emanote-static-website-${name}" { meta.description = "Contents of the statically-generated Emanote website for ${name}"; }