Пример #1
0
        private Task<Errorable> processDoctypeElement(RenderState st)
        {
            // <cms-doctype type="html" />
            //   generates
            // <!DOCTYPE html>
            
            var xr = st.Reader;

            if (!xr.IsEmptyElement)
            {
                st.Error("cms-doctype must be an empty element");
                st.SkipElementAndChildren("cms-doctype");
                return Task.FromResult(Errorable.NoErrors);
            }

            if (!xr.MoveToAttribute("type"))
            {
                st.Error("cms-doctype must have a type attribute");
                xr.MoveToElement();
                return Task.FromResult(Errorable.NoErrors);
            }

            string type = xr.Value;
            xr.MoveToElement();

            if (type == "html")
            {
                // HTML5 doctype:
                st.Writer.Append("<!DOCTYPE html>\r\n\r\n");
                return Task.FromResult(Errorable.NoErrors);
            }
            else
            {
                st.Error("cms-doctype has unknown type value '{0}'", type);
                return Task.FromResult(Errorable.NoErrors);
            }
        }
        private async Task<Errorable> processScheduledElement(RenderState st)
        {
            // Specifies that content should be scheduled for the entire month of August
            // and the entire month of October but NOT the month of September.
            // 'from' is inclusive date/time.
            // 'to'   is exclusive date/time.
            // <cms-scheduled>
            //   <range from="2011-08-01 00:00 -0500" to="2011-09-01 00:00 -0500" />
            //   <range from="2011-10-01 00:00 -0500" to="2011-11-01 00:00 -0500" />
            //   <content>
            //     content to show if scheduled (recursively including other cms- elements)
            //   </content>
            // [optional:]
            //   <else>
            //     content to show if not scheduled (recursively including other cms- elements)
            //   </else>
            // </cms-scheduled>

            bool displayContent = false;
            bool hasRanges = false;
            bool hasContent = false;
            bool hasElse = false;

            XmlTextReader xr = st.Reader;

            int knownDepth = xr.Depth;
            while (xr.Read() && xr.Depth > knownDepth)
            {
                if (xr.NodeType != XmlNodeType.Element) continue;

                if (xr.LocalName == "range")
                {
                    hasRanges = true;

                    if (!xr.IsEmptyElement)
                    {
                        st.Error("'range' element must be empty");
                        // Skip to end of cms-scheduled element and exit.
                        st.SkipElementAndChildren("range");
                        continue;
                    }

                    // If we're already good to display, don't bother evaluating further schedule ranges:
                    if (displayContent)
                        // Safe to continue here because the element is empty; no more to parse.
                        continue;

                    string fromAttr, toAttr;

                    // Validate the element's form:
                    if (!xr.HasAttributes) st.Error("range element must have attributes");
                    if ((fromAttr = xr.GetAttribute("from")) == null) st.Error("'range' element must have 'from' attribute");
                    // 'to' attribute is optional:
                    toAttr = xr.GetAttribute("to");

                    // Parse the dates:
                    DateTimeOffset fromDate, toDateTmp;
                    DateTimeOffset toDate;

                    if (!DateTimeOffset.TryParse(fromAttr, out fromDate))
                    {
                        st.Error("could not parse 'from' attribute as a date/time");
                        continue;
                    }
                    if (!String.IsNullOrWhiteSpace(toAttr))
                    {
                        if (DateTimeOffset.TryParse(toAttr, out toDateTmp))
                            toDate = toDateTmp;
                        else
                        {
                            st.Error("could not parse 'to' attribute as a date/time");
                            continue;
                        }
                    }
                    else
                    {
                        toDate = st.Engine.ViewDate;
                    }

                    // Validate the range's dates are ordered correctly:
                    if (toDate <= fromDate) st.Error("'to' date must be later than 'from' date or empty");

                    // Check the schedule range:
                    displayContent = (st.Engine.ViewDate >= fromDate && st.Engine.ViewDate < toDate);
                }
                else if (xr.LocalName == "content")
                {
                    if (hasElse)
                    {
                        st.Error("'content' element must come before 'else' element");
                        st.SkipElementAndChildren("content");
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "content" */);
                        continue;
                    }

                    if (hasContent)
                    {
                        st.Error("only one 'content' element may exist in cms-scheduled");
                        st.SkipElementAndChildren("content");
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "content" */);
                        continue;
                    }

                    hasContent = true;
                    if (!hasRanges)
                    {
                        st.Error("no 'range' elements found before 'content' element in cms-scheduled");
                        displayContent = false;
                    }

                    if (displayContent)
                    {
                        // Stream the inner content into the StringBuilder until we get back to the end </content> element.
                        await st.CopyElementChildren("content").ConfigureAwait(continueOnCapturedContext: false);
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "content" */);
                    }
                    else
                    {
                        // Skip the inner content entirely:
                        st.SkipElementAndChildren("content");
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "content" */);
                    }
                }
                else if (xr.LocalName == "else")
                {
                    if (!hasContent)
                    {
                        st.Error("'content' element must come before 'else' element");
                        st.SkipElementAndChildren("else");
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "else" */);
                        continue;
                    }
                    if (hasElse)
                    {
                        st.Error("only one 'else' element may exist in cms-scheduled");
                        st.SkipElementAndChildren("else");
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "else" */);
                        continue;
                    }

                    hasElse = true;
                    if (!hasRanges)
                    {
                        st.Error("no 'range' elements found before 'else' element in cms-scheduled");
                        st.SkipElementAndChildren("else");
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "else" */);
                        continue;
                    }

                    if (!displayContent)
                    {
                        // Stream the inner content into the StringBuilder until we get back to the end </content> element.
                        await st.CopyElementChildren("else").ConfigureAwait(continueOnCapturedContext: false);
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "else" */);
                    }
                    else
                    {
                        // Skip the inner content entirely:
                        st.SkipElementAndChildren("else");
                        if (!xr.IsEmptyElement)
                            xr.ReadEndElement(/* "else" */);
                    }
                }
                else
                {
                    st.Error("unexpected element '{0}'", xr.LocalName);
                }
            }

            // Report errors:
            if (!hasContent && !hasRanges) st.Error("no 'range' elements found");
            if (!hasContent) st.Error("no 'content' element found");

            // Skip Whitespace and Comments etc. until we find the end element:
            while (xr.NodeType != XmlNodeType.EndElement && xr.Read()) { }

            // Validate:
            if (xr.LocalName != "cms-scheduled") st.Error("expected end <cms-scheduled/> element");

            // Don't read this element because the next `xr.Read()` in the main loop will:
            //xr.ReadEndElement(/* "cms-scheduled" */);
            return Errorable.NoErrors;
        }
        private async Task<Errorable> processImportTemplateElement(RenderState st)
        {
            // Imports content directly from another blob, addressable by a relative path or an absolute path.
            // Relative path is always relative to the current blob's absolute path.
            // In the case of nested imports, relative paths are relative to the absolute path of the importee's parent blob.

            // <cms-import-template path="/template/main">
            //   <area id="head">
            //     <link rel="" />
            //   </area>
            //   <area id="body">
            //     <div>
            //       ...
            //     </div>
            //   </area>
            // </cms-import-template>

            // Absolute paths are canonicalized. An exception will be thrown if the path contains too many '..' references that
            // bring the canonicalized path above the root of the tree (which is impossible).

            // Recursively call RenderBlob on the imported blob and include the rendered HTMLFragment into this rendering.

            var xr = st.Reader;

            // st.Reader is pointing to "cms-import-template" Element.

            if (!xr.HasAttributes || !xr.MoveToAttribute("path"))
            {
                st.Error("cms-import-template requires a 'path' attribute");
                st.SkipElementAndChildren("cms-import-template");
                return Errorable.NoErrors;
            }

            string ncpath = xr.Value;
            st.Reader.MoveToElement();

            TreePathStreamedBlob tmplBlob;

            // Canonicalize the absolute or relative path relative to the current item's path:
            var abspath = PathObjectModel.ParseBlobPath(ncpath);
            CanonicalBlobPath path = abspath.Collapse(abs => abs, rel => (st.Item.TreeBlobPath.Path.Tree + rel)).Canonicalize();

            // Fetch the Blob given the absolute path constructed:
            TreeBlobPath tbp = new TreeBlobPath(st.Item.TreeBlobPath.RootTreeID, path);
            var etmplBlob = await st.Engine.TreePathStreamedBlobs.GetBlobByTreePath(tbp).ConfigureAwait(continueOnCapturedContext: false);
            if (etmplBlob.HasErrors)
            {
                st.SkipElementAndChildren("cms-import-template");

                // Check if the error is a simple blob not found error:
                bool notFound = etmplBlob.Errors.Any(er => er is BlobNotFoundByPathError);
                if (notFound)
                {
                    st.Error("cms-import-template could not find blob by path '{0}' off tree '{1}'", tbp.Path.ToString(), tbp.RootTreeID.ToString());
                    return Errorable.NoErrors;
                }

                // Error was more serious:
                foreach (var err in etmplBlob.Errors.Errors)
                {
                    st.Error(err.Message);
                }
                return etmplBlob.Errors;
            }
            else tmplBlob = etmplBlob.Value;

            Debug.Assert(tmplBlob != null);

            // This lambda processes the entire imported template:
            Func<RenderState, Task<Errorable<bool>>> processElements = (Func<RenderState, Task<Errorable<bool>>>)(async sst =>
            {
                // Make sure cms-template is the first element from the imported template blob:
                if (sst.Reader.LocalName != "cms-template")
                {
                    sst.Error("cms-import-template expected cms-template as first element of imported template");
                    sst.SkipElementAndChildren(sst.Reader.LocalName);
                    st.SkipElementAndChildren("cms-import-template");
                    return false;
                }

                // Don't move the st.Reader yet until we know the cms-import-template has a cms-template-area in it:
                string fillerAreaId = null;
                bool isFirstArea = !st.Reader.IsEmptyElement;

                // Create a new RenderState that reads from the parent blob and writes to the template's renderer:
                var stWriter = new RenderState(st.Engine, st.Item, st.Reader, sst.Writer);

                // This lambda is called recursively to handle cms-template-area elements found within parent cms-template-area elements in the template:
                Func<RenderState, Task<Errorable<bool>>> processTemplateAreaElements = null;
                processTemplateAreaElements = (Func<RenderState, Task<Errorable<bool>>>)(async tst =>
                {
                    // Only process cms-template-area elements:
                    if (tst.Reader.LocalName != "cms-template-area")
                    {
                        // Use DefaultProcessElements to handle processing other cms- custom elements from the template:
                        return await RenderState.DefaultProcessElements(tst);
                    }

                    // Read the cms-template-area's id attribute:
                    if (!tst.Reader.MoveToAttribute("id"))
                    {
                        tst.Error("cms-template-area needs an 'id' attribute");
                        tst.Reader.MoveToElement();
                        tst.SkipElementAndChildren("cms-template-area");
                        return false;
                    }

                    // Assign the template's area id:
                    string tmplAreaId = tst.Reader.Value;

                    // Move to the first area if we have to:
                    if (isFirstArea)
                    {
                        if (!st.Reader.IsEmptyElement)
                        {
                            fillerAreaId = moveToNextAreaElement(st);
                        }
                        isFirstArea = false;
                    }

                    // Do the ids match?
                    if ((fillerAreaId != null) && (tmplAreaId == fillerAreaId))
                    {
                        // Skip the cms-template-area in the template:
                        tst.Reader.MoveToElement();
                        tst.SkipElementAndChildren("cms-template-area");
                        // Move the filler reader to the element node:
                        st.Reader.MoveToElement();
                        // Copy the elements:
                        await stWriter.CopyElementChildren("area");

                        // Move to the next area element, if available:
                        fillerAreaId = moveToNextAreaElement(st);
                    }
                    else
                    {
                        // Insert the default content from the template:
                        tst.Reader.MoveToElement();
                        // Recurse into children, allowing processing of embedded cms-template-areas:
                        await tst.CopyElementChildren("cms-template-area", null, processTemplateAreaElements);
                    }

                    // We handled this:
                    return false;
                });

                // Now continue on stream-copying child elements until we find a cms-template-area:
                var err = await sst.CopyElementChildren("cms-template", null, processTemplateAreaElements)
                    .ConfigureAwait(continueOnCapturedContext: false);
                if (err.HasErrors) return err.Errors;

                // We missed some <area />s in the cms-import-template:
                while (!((st.Reader.NodeType == XmlNodeType.EndElement) && (st.Reader.LocalName == "cms-import-template")) &&
                       !((st.Reader.NodeType == XmlNodeType.Element) && st.Reader.IsEmptyElement && (st.Reader.LocalName == "cms-import-template")))
                {
                    // Move to the next <area /> start element:
                    fillerAreaId = moveToNextAreaElement(st);
                    if (fillerAreaId != null)
                    {
                        st.Warning("area '{0}' unused by the template", fillerAreaId);
                        st.SkipElementAndChildren("area");
                    }
                }

                return false;
            });

            // Process the imported cms-template and inject the <area /> elements from the current <cms-import-template /> element:
            RenderState importedTemplate = new RenderState(
                st.Engine,
                tmplBlob,

                earlyExit: (Func<RenderState, bool>)(sst =>
                {
                    return false;
                }),

                processElements: processElements,
                previous: st
            );

            // Render the template:
            var esbResult = await importedTemplate.Render().ConfigureAwait(continueOnCapturedContext: false);
            if (esbResult.HasErrors)
            {
                foreach (var err in esbResult.Errors)
                    st.Error(err.Message);
                return esbResult.Errors;
            }

            StringBuilder sbResult = esbResult.Value;
            string blob = sbResult.ToString();

            // Write the template to our current writer:
            st.Writer.Append(blob);

            return Errorable.NoErrors;
        }
        private static string moveToNextAreaElement(RenderState st, bool suppressErrors = false)
        {
            // Now, back on the cms-import-template element from the parent blob, read up to the first child element:
            do
            {
                // Skip the opening element:
                if ((st.Reader.NodeType == System.Xml.XmlNodeType.Element) && (st.Reader.LocalName == "cms-import-template"))
                {
                    if (!st.Reader.IsEmptyElement) continue;
                    // Empty?
                    return null;
                }

                // Early out case:
                if ((st.Reader.NodeType == System.Xml.XmlNodeType.EndElement) && (st.Reader.LocalName == "cms-import-template"))
                    return null;

                if (st.Reader.NodeType == System.Xml.XmlNodeType.Element)
                {
                    // Only <area /> elements are allowed within <cms-import-template />.
                    if (st.Reader.LocalName != "area")
                    {
                        if (!suppressErrors) st.Error("cms-import-template may only contain 'area' elements");
                        st.SkipElementAndChildren(st.Reader.LocalName);
                        return null;
                    }

                    // Need an 'id' attribute:
                    if (!st.Reader.MoveToAttribute("id"))
                    {
                        if (!suppressErrors) st.Error("area element must have an 'id' attribute");
                        st.Reader.MoveToElement();
                        st.SkipElementAndChildren("area");
                        return null;
                    }

                    string id = st.Reader.Value;
                    st.Reader.MoveToElement();

                    // Return the new area's id:
                    return id;
                }
            } while (st.Reader.Read());

            return null;
        }
Пример #5
0
        private async Task<Errorable> processImportElement(RenderState st)
        {
            // Imports content directly from another blob, addressable by a relative path or an absolute path.
            // Relative path is always relative to the current blob's absolute path.
            // In the case of nested imports, relative paths are relative to the absolute path of the importee's parent blob.

            // <cms-import path="../templates/main" />
            // <cms-import path="/templates/main" />

            // Absolute paths are canonicalized. An exception will be thrown if the path contains too many '..' references that
            // bring the canonicalized path above the root of the tree (which is impossible).

            // Recursively call RenderBlob on the imported blob and include the rendered HTMLFragment into this rendering.

            // st.Reader is pointing to "cms-import" Element.
            if (!st.Reader.IsEmptyElement) st.Error("cms-import element must be empty");

            if (st.Reader.HasAttributes && st.Reader.MoveToFirstAttribute())
            {
                string ncpath = st.Reader.GetAttribute("path");
                string blob;
                TreePathStreamedBlob tpsBlob;

                // Fetch the TreePathStreamedBlob for the given path:
                // Canonicalize the absolute or relative path relative to the current item's path:
                var abspath = PathObjectModel.ParseBlobPath(ncpath);
                CanonicalBlobPath path = abspath.Collapse(abs => abs, rel => (st.Item.TreeBlobPath.Path.Tree + rel)).Canonicalize();

                // Fetch the Blob given the absolute path constructed:
                TreeBlobPath tbp = new TreeBlobPath(st.Item.TreeBlobPath.RootTreeID, path);
                var etpsBlob = await st.Engine.TreePathStreamedBlobs.GetBlobByTreePath(tbp).ConfigureAwait(continueOnCapturedContext: false);
                if (etpsBlob.HasErrors)
                {
                    st.SkipElementAndChildren("cms-import");

                    // Check if the error is a simple blob not found error:
                    bool notFound = etpsBlob.Errors.Any(er => er is BlobNotFoundByPathError);
                    if (notFound)
                    {
                        st.Error("cms-import could not find blob by path '{0]' off tree '{1}'", tbp.Path, tbp.RootTreeID);
                        return Errorable.NoErrors;
                    }

                    // Error was more serious:
                    foreach (var err in etpsBlob.Errors)
                        st.Error(err.Message);
                    return etpsBlob.Errors;
                }
                else tpsBlob = etpsBlob.Value;

                Debug.Assert(tpsBlob != null);

                // Fetch the contents for the given TreePathStreamedBlob:
                // TODO: we could probably asynchronously load blobs and render their contents
                // then at a final sync point go in and inject their contents into the proper
                // places in each imported blob's parent StringBuilder.

                // Render the blob inline:
                RenderState rsInner = new RenderState(st.Engine, tpsBlob);
                var einnerSb = await rsInner.Render().ConfigureAwait(continueOnCapturedContext: false);
                if (einnerSb.HasErrors)
                {
                    foreach (var err in einnerSb.Errors)
                        st.Error(err.Message);
                    return einnerSb.Errors;
                }

                blob = einnerSb.Value.ToString();

                st.Writer.Append(blob);

                // Move the reader back to the element node:
                st.Reader.MoveToElement();
            }

            return Errorable.NoErrors;
        }
        private async Task<Errorable> processConditionalElement(RenderState st)
        {
            // <cms-conditional>
            //     <if department="Sales">Hello, Sales dept!</if>
            //     <elif department="Accounting">Hello, Accounting dept!</elif>
            //     <elif department="Management">Hello, Management dept!</elif>
            //     <else>Hello, unknown dept!</else>
            // </cms-conditional>

            //st.SkipElementAndChildren("cms-conditional");

            Errorable err;
            ConditionalState c = ConditionalState.ExpectingIf;
            bool satisfied = false;
            bool condition = false;
            Dictionary<string, string> conditionVariables;

            int knownDepth = st.Reader.Depth;
            while (st.Reader.Read() && st.Reader.Depth > knownDepth)
            {
                // FIXME: should be non-whitespace check
                if (st.Reader.NodeType != XmlNodeType.Element) continue;

                switch (c)
                {
                    case ConditionalState.ExpectingIf:
                        if (st.Reader.LocalName != "if")
                        {
                            st.Error("expected 'if' element");
                            goto errored;
                        }

                        // Update state to expect 'elif' or 'else' elements:
                        c = ConditionalState.ExpectingElseOrElIf;
                        goto processCondition;
                    
                    case ConditionalState.ExpectingElseOrElIf:
                        if (st.Reader.LocalName == "elif")
                        {
                            c = ConditionalState.ExpectingElseOrElIf;
                            goto processCondition;
                        }
                        else if (st.Reader.LocalName == "else")
                        {
                            c = ConditionalState.ExpectingEnd;
                            goto processElse;
                        }
                        else
                        {
                            st.Error("expected 'elif' or 'else' element");
                            goto errored;
                        }

                    case ConditionalState.ExpectingEnd:
                        st.Error("expected </cms-conditional> end element");
                        break;

                    processCondition:
                        // Parse out the condition test variables:
                        conditionVariables = new Dictionary<string, string>(StringComparer.Ordinal);
                        if (st.Reader.HasAttributes && st.Reader.MoveToFirstAttribute())
                        {
                            do
                            {
                                conditionVariables.Add(st.Reader.LocalName, st.Reader.Value);
                            } while (st.Reader.MoveToNextAttribute());

                            st.Reader.MoveToElement();
                        }

                        // Make sure we have at least one:
                        if (conditionVariables.Count == 0)
                        {
                            st.Error("expected at least one attribute for '{0}' element", st.Reader.LocalName);
                            goto errored;
                        }

                        // Make sure the branch has not already been satisfied:
                        if (satisfied)
                        {
                            // Branch has already been satisfied, skip inner contents:
                            st.SkipElementAndChildren(st.Reader.LocalName);
                            break;
                        }

                        // Run the condition test variables through the evaluator chain:
                        IConditionalEvaluator eval = evaluator;
                        EitherAndOr? lastAndOr = null;
                        
                        while (eval != null)
                        {
                            Errorable<bool> etest = await eval.EvaluateConditional(conditionVariables);
                            if (etest.HasErrors) return etest.Errors;
                            
                            bool test = etest.Value;

                            if (lastAndOr.HasValue)
                            {
                                if (lastAndOr.Value == EitherAndOr.And) condition = condition && test;
                                else condition = condition || test;
                            }
                            else
                            {
                                condition = test;
                            }

                            lastAndOr = eval.AndOr;
                            eval = eval.Next;
                        }

                        // Now either render the inner content or skip it based on the `condition` evaluated:
                        if (condition)
                        {
                            satisfied = true;
                            // Copy inner contents:
                            err = await st.CopyElementChildren(st.Reader.LocalName).ConfigureAwait(continueOnCapturedContext: false);
                            if (err.HasErrors) return err.Errors;
                        }
                        else
                        {
                            // Skip inner contents:
                            st.SkipElementAndChildren(st.Reader.LocalName);
                        }
                        break;

                    processElse:
                        if (st.Reader.HasAttributes)
                        {
                            st.Error("unexpected attributes on 'else' element");
                            goto errored;
                        }

                        if (satisfied)
                        {
                            // Skip inner contents:
                            st.SkipElementAndChildren(st.Reader.LocalName);
                            break;
                        }

                        // Copy inner contents:
                        err = await st.CopyElementChildren(st.Reader.LocalName).ConfigureAwait(continueOnCapturedContext: false);
                        if (err.HasErrors) return err.Errors;
                        break;
                }
            }
            return Errorable.NoErrors;

        errored:
            // Keep reading to the end cms-conditional element:
            while (st.Reader.Read() && st.Reader.Depth > knownDepth) { }
            return Errorable.NoErrors;
        }
Пример #7
0
        private async Task<Errorable> processLinkElement(RenderState st)
        {
            // A 'cms-link' is translated directly into an anchor tag with the 'path' attribute
            // translated and canonicalized into an absolute 'href' attribute, per system
            // configuration. All other attributes are copied to the anchor tag as-is.

            // e.g.:
            //   <cms-link path="/absolute/path" ...>contents</cms-link>
            // becomes:
            //   <a href="/content/absolute/path" ...>contents</a>
            // if the CMS requests are "mounted" to the /content/ root URL path.

            // Either relative or absolute paths are allowed for 'path':
            //   <cms-link path="../../hello/world" target="_blank">Link text.</cms-link>
            //   <cms-link path="/hello/world" target="_blank">Link text.</cms-link>

            // Set the current element depth so we know where to read up to on error:
            int knownDepth = st.Reader.Depth;
            bool isEmpty = st.Reader.IsEmptyElement;

            if (!st.Reader.HasAttributes)
            {
                st.Error("cms-link has no attributes");
                goto errored;
            }

            bool foundPath = false;

            st.Writer.Append("<a");

            st.Reader.MoveToFirstAttribute();
            do
            {
                string value = st.Reader.Value;

                if (st.Reader.LocalName == "path")
                {
                    foundPath = true;

                    // Get the canonicalized blob path (from either absolute or relative):
                    var abspath = PathObjectModel.ParseBlobPath(value);
                    CanonicalBlobPath path = abspath.Collapse(abs => abs.Canonicalize(), rel => (st.Item.TreeBlobPath.Path.Tree + rel).Canonicalize());
                    
                    // TODO: apply the reverse-mount prefix path from the system configuration,
                    // or just toss the CanonicalBlobPath over to a provider implementation and
                    // it can give us the final absolute URL path.
                    st.Writer.AppendFormat(" href=\"{0}\"", path);
                    continue;
                }

                // Append the normal attribute:
                st.Writer.AppendFormat(" {0}={2}{1}{2}", st.Reader.LocalName, value, st.Reader.QuoteChar);
            } while (st.Reader.MoveToNextAttribute());

            // Jump back to the element node from the attributes:
            st.Reader.MoveToElement();

            if (!foundPath)
            {
                // Issue a warning and append an "href='#'" attribute:
                st.WarningSuppressComment("expected 'path' attribute on 'cms-link' element was not found");
                st.Writer.Append(" href=\"#\"");
            }

            // Self-close the <a /> if the <cms-link /> is empty:
            if (isEmpty)
            {
                st.Writer.Append(" />");
                return Errorable.NoErrors;
            }

            // Copy the inner contents and close out the </a>.
            st.Writer.Append(">");
            var err = await st.CopyElementChildren("cms-link").ConfigureAwait(continueOnCapturedContext: false);
            if (err.HasErrors) return err.Errors;
            st.Writer.Append("</a>");
            return Errorable.NoErrors;

        errored:
            // Skip to the end of the cms-link element:
            if (!isEmpty)
                while (st.Reader.Read() && st.Reader.Depth > knownDepth) { }
            return Errorable.NoErrors;
        }