package com.psddev.cms.db;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.ListIterator;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import com.psddev.dari.db.Modification;
import com.psddev.dari.db.ObjectType;
import com.psddev.dari.db.Record;
import com.psddev.dari.db.State;
import com.psddev.dari.util.ErrorUtils;
import com.psddev.dari.util.HtmlElement;
import com.psddev.dari.util.HtmlNode;
import com.psddev.dari.util.HtmlText;
import com.psddev.dari.util.ObjectUtils;
import com.psddev.dari.util.TypeDefinition;
/**
* Holds various data on how to render a page. This class is commonly used to
* easily update and render the contents of the {@code <head>} element.
*
* <p>For example, given the following class:</p>
*
* <blockquote><pre data-type="java">
*public class Article extends Content implements PageStage.Updatable {
*
* private String title;
*
* public String getTitle() {
* return title;
* }
*
* {@literal @}Override
* public void updateStage(PageStage stage) {
* stage.setTitle(getTitle());
* }
*}
* </pre></blockquote>
*
* <p>The following JSP fragment:</p>
*
* <blockquote><pre data-type="jsp">
*<head>
*<cms:render value="${stage.headNodes}" />
*</head>
* </pre></blockquote>
*
* <p>Will output:</p>
*
* <blockquote><pre data-type="html">{@literal
*<head>
*<title>The Article Title</title>
*</head>
* }</pre></blockquote>
*
* <p>Additionally, classes can share the update logic using the
* {@link UpdateClass} annotation:</p>
*
* <blockquote><pre data-type="java">
*public class DefaultPageStageUpdater implements PageStage.SharedUpdatable {
*
* {@literal @}Override
* public void updateStageBefore(Object object, PageStage stage) {
* }
*
* {@literal @}Override
* public void updateStageAfter(Object object, PageStage stage) {
* stage.setTitle(stage.getTitle() + " | Site Name");
* }
*}
*
*{@literal @}PageStage.UpdateClass(DefaultPageStageUpdater.class)
*public class Article extends Content {
*}
* </pre></blockquote>
*/
public class PageStage extends Record {
private transient ServletContext servletContext;
private transient HttpServletRequest request;
private final transient List<HtmlNode> headNodes = new ArrayList<HtmlNode>();
public PageStage() {
}
public PageStage(ServletContext servletContext, HttpServletRequest request) {
this.servletContext = servletContext;
this.request = request;
}
/**
* Returns the servlet context associated with this page stage.
*
* @return May be {@code null}.
*/
public ServletContext getServletContext() {
return servletContext;
}
/**
* Returns the request associated with this page stage.
*
* @return May be {@code null}.
*/
public HttpServletRequest getRequest() {
return request;
}
/**
* Returns the list of all nodes in the {@code <head>} element.
*
* @return Never {@code null}. Mutable.
*/
public List<HtmlNode> getHeadNodes() {
return headNodes;
}
/**
* Finds an element with the the given {@code name} and
* {@code attributes} within the {@code <head>}.
*
* @param name Can't be blank.
* @param attributes May be {@code null}.
* @return May be {@code null}.
*/
public HtmlElement findHeadElement(String name, Object... attributes) {
ErrorUtils.errorIfBlank(name, "name");
for (HtmlNode node : getHeadNodes()) {
if (!(node instanceof HtmlElement)) {
continue;
}
HtmlElement element = (HtmlElement) node;
if (name.equals(element.getName()) &&
element.hasAttributes(attributes)) {
return element;
}
}
return null;
}
/**
* Finds or creates an element with the given {@code name} and
* {@code attributes} within the {@code <head>}.
*
* <p>Note that the stylesheet elements are grouped first, followed by
* all non-script elements, followed by script elements, with insertion
* order within the groups preserved.</p>
*
* @param name Can't be blank.
* @param attributes May be {@code null}.
* @return Never {@code null}.
*/
public HtmlElement findOrCreateHeadElement(String name, Object... attributes) {
HtmlElement element = findHeadElement(name, attributes);
if (element == null) {
element = new HtmlElement();
element.setName(name);
element.addAttributes(attributes);
List<HtmlNode> nodes = getHeadNodes();
if (ObjectUtils.isBlank(nodes)) {
nodes.add(element);
} else {
// JS goes last.
if ("script".equals(name)) {
nodes.add(element);
// CSS goes first.
} else if ("link".equals(name) &&
"text/css".equals(element.getAttributes().get("type"))) {
int insertIndex = 0;
for (ListIterator<HtmlNode> i = nodes.listIterator(); i.hasNext();) {
HtmlNode node = i.next();
if (!(node instanceof HtmlElement)) {
continue;
}
HtmlElement iElement = (HtmlElement) node;
if ("link".equals(iElement.getName()) &&
"text/css".equals(iElement.getAttributes().get("type"))) {
continue;
} else {
insertIndex = i.previousIndex();
break;
}
}
nodes.add(insertIndex, element);
// Everything else in between.
} else {
int insertIndex = 0;
for (ListIterator<HtmlNode> i = nodes.listIterator(); i.hasNext();) {
HtmlNode node = i.next();
if (!(node instanceof HtmlElement)) {
continue;
}
HtmlElement iElement = (HtmlElement) node;
if ("script".equals(iElement.getName())) {
insertIndex = i.previousIndex();
break;
} else {
insertIndex = i.nextIndex();
}
}
nodes.add(insertIndex, element);
}
}
}
return element;
}
/**
* Removes all tags with the given {@code name} and {@code attributes}
* within the {@code <head>} element.
*
* @param name If blank, does nothing.
* @param attribues May be {@code null}.
*/
public void removeHeadTag(String name, Object... attributes) {
if (ObjectUtils.isBlank(name)) {
return;
}
for (Iterator<HtmlNode> i = getHeadNodes().iterator(); i.hasNext();) {
HtmlNode node = i.next();
if (!(node instanceof HtmlElement)) {
continue;
}
HtmlElement element = (HtmlElement) node;
if (name.equals(element.getName()) &&
element.hasAttributes(attributes)) {
i.remove();
}
}
}
/**
* Returns the title (the text in the {@code <title>} element).
*
* @return Never {@code null}.
*/
public String getTitle() {
StringBuilder titleText = new StringBuilder();
HtmlElement titleElement = findHeadElement("title");
if (titleElement != null) {
for (HtmlNode child : titleElement.getChildren()) {
if (child instanceof HtmlText) {
titleText.append(((HtmlText) child).getText());
}
}
}
return titleText.toString();
}
/**
* Sets the title (the text in the {@code <title>} element).
*
* @param title If {@code null}, removes the element.
*/
public void setTitle(String title) {
if (ObjectUtils.isBlank(title)) {
removeHeadTag("title");
} else {
HtmlText text = new HtmlText();
List<HtmlNode> children = findOrCreateHeadElement("title").getChildren();
text.setText(title);
children.clear();
children.add(text);
}
setMetaProperty("og:title", title);
}
/**
* Returns the description (the value of the {@code content} attribute
* in either {@code <meta name="description">} or
* {@code <meta property="og:description">}).
*
* @return Never {@code null}.
*/
public String getDescription() {
HtmlElement descriptionElement = findHeadElement("meta", "name", "description");
if (descriptionElement == null) {
descriptionElement = findHeadElement("meta", "property", "og:description");
}
if (descriptionElement != null) {
String description = descriptionElement.getAttributes().get("content");
if (description != null) {
return description;
}
}
return "";
}
/**
* Sets the description (the value of the {@code content} attribute
* in both {@code <meta name="description" content="description">}
* and {@code <meta property="og:description" content="description">}).
*
* @param description If {@code null}, removes the elements.
*/
public void setDescription(String description) {
setMetaName("description", description);
setMetaProperty("og:description", description);
}
/**
* Returns the canonical URL (the value of the {@code href} attribute
* in {@code <link rel="canonical">}.
*
* @return May be {@code null}.
*/
public String getCanonicalUrl() {
HtmlElement urlElement = findHeadElement("link", "rel", "canonical");
return urlElement != null ?
urlElement.getAttributes().get("href") :
null;
}
/**
* Sets the canonical URL (the value of the {@code href} attribute in
* {@code <link rel="canonical">}.
*
* @param canonicalUrl If {@code null}, removes the element.
*/
public void setCanonicalUrl(String canonicalUrl) {
if (ObjectUtils.isBlank(canonicalUrl)) {
removeHeadTag("link", "rel", "canonical");
} else {
findOrCreateHeadElement("link", "rel", "canonical").getAttributes().put("href", canonicalUrl);
}
}
/**
* Sets {@code <meta name="name" content="content">}.
*
* @param name If blank, does nothing.
* @param content If blank, removes the tag.
*/
public void setMetaName(String name, String content) {
if (!ObjectUtils.isBlank(name)) {
if (ObjectUtils.isBlank(content)) {
removeHeadTag("meta", "name", name);
} else {
findOrCreateHeadElement("meta", "name", name).getAttributes().put("content", content);
}
}
}
/**
* Sets {@code <meta property="property" content="content">}.
*
* @param property If blank, does nothing.
* @param content If blank, removes the tag.
*/
public void setMetaProperty(String property, String content) {
if (!ObjectUtils.isBlank(property)) {
if (ObjectUtils.isBlank(content)) {
removeHeadTag("meta", "property", property);
} else {
findOrCreateHeadElement("meta", "property", property).getAttributes().put("content", content);
}
}
}
/**
* Sets {@code <meta http-equiv="httpEquiv" content="content">}.
*
* @param httpEquiv If blank, does nothing.
* @param content If blank, removes the tag.
*/
public void setHttpEquiv(String httpEquiv, String content) {
if (!ObjectUtils.isBlank(httpEquiv)) {
if (ObjectUtils.isBlank(content)) {
removeHeadTag("meta", "http-equiv", httpEquiv);
} else {
findOrCreateHeadElement("meta", "http-equiv", httpEquiv).getAttributes().put("content", content);
}
}
}
/**
* Adds {@code <link rel="stylsheet" type="text/css" href="href">}.
*
* @param href If blank, does nothing.
*/
public void addStyleSheet(String href) {
if (!ObjectUtils.isBlank(href)) {
findOrCreateHeadElement("link",
"rel", href.endsWith(".less") ? "stylesheet/less" : "stylesheet",
"type", "text/css",
"href", ElFunctionUtils.resource(href));
}
}
/**
* Adds {@code <script type="text/javascript" src="src"></script>}
* within the {@code <head>} element.
*
* @param src If blank, does nothing.
*/
public void addScript(String src) {
if (!ObjectUtils.isBlank(src)) {
findOrCreateHeadElement("script",
"type", "text/javascript",
"src", ElFunctionUtils.resource(src));
}
}
/**
* Updates this stage using the given {@code object}.
*
* @param object Can't be {@code null}.
*/
public void update(Object object) {
State state = State.getInstance(object);
ObjectType type = state.getType();
SharedUpdatable sharedUpdatable = type != null ?
type.as(TypeData.class).createSharedUpdatable() :
null;
if (sharedUpdatable != null) {
sharedUpdatable.updateStageBefore(object, this);
}
if (object instanceof Updatable) {
((Updatable) object).updateStage(this);
}
if (sharedUpdatable != null) {
sharedUpdatable.updateStageAfter(object, this);
}
}
/**
* Specifies the {@link Updatable} class that will update the
* {@link PageStage} for all instances of the target type. If the class
* directly implements {@link Updatable}, this will run after.
*/
@Documented
@Inherited
@ObjectType.AnnotationProcessorClass(UpdateClassProcessor.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface UpdateClass {
Class<? extends SharedUpdatable> value();
}
private static class UpdateClassProcessor implements ObjectType.AnnotationProcessor<UpdateClass> {
@Override
public void process(ObjectType type, UpdateClass annotation) {
type.as(TypeData.class).setUpdateClassName(annotation.value().getName());
}
}
/**
* Type modification that adds {@link PageStage}-specific data.
*/
@FieldInternalNamePrefix("cms.pageStage.")
public static class TypeData extends Modification<ObjectType> {
private String updateClassName;
public String getUpdateClassName() {
return updateClassName;
}
public void setUpdateClassName(String updateClassName) {
this.updateClassName = updateClassName;
}
/**
* Creates a shared updatable object appropriate for this type.
*
* @return May be {@code null}.
*/
@SuppressWarnings("unchecked")
public SharedUpdatable createSharedUpdatable() {
Class<?> c = ObjectUtils.getClassByName(getUpdateClassName());
return c != null && SharedUpdatable.class.isAssignableFrom(c) ?
TypeDefinition.getInstance((Class<? extends SharedUpdatable>) c).newInstance() :
null;
}
}
/**
* {@link PageFilter} will call {@link #updateStage} before rendering
* if the main content implements this interface.
*/
public static interface Updatable {
/**
* Updates the given {@code stage}. This is typically used to set
* the nodes within the {@code <head>} element, such as the
* {@code <title>}.
*
* @param stage Can't be {@code null}.
*/
public void updateStage(PageStage stage);
}
/**
* {@link PageFilter} will call the appropriate methods if the main
* content is annotated with {@link UpdateClass} that points to a class
* that implements this interface.
*/
public static interface SharedUpdatable {
/**
* Updates the given {@code stage} before the {@code object}-specific
* logic executes. This is typically used to set the defaults.
*
* @param object Can't be {@code nul}.
* @param stage Can't be {@code null}.
*/
public void updateStageBefore(Object object, PageStage stage);
/**
* Updates the given {@code stage} after the {@code object}-specific
* logic executes. This is typically used to add common extra data,
* such as adding the site name to the page title.
*
* @param object Can't be {@code nul}.
* @param stage Can't be {@code null}.
*/
public void updateStageAfter(Object object, PageStage stage);
}
}